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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ claude --plugin-dir ./apps/hook
| `JINA_API_KEY` | Optional Jina Reader API key for higher rate limits (500 RPM vs 20 RPM unauthenticated). Free keys include 10M tokens. |
| `PLANNOTATOR_VERIFY_ATTESTATION` | **Read by the install scripts only**, not by the runtime binary. Set to `1` / `true` to have `scripts/install.sh` / `install.ps1` / `install.cmd` run `gh attestation verify` on every install. Off by default. Can also be set persistently via `~/.plannotator/config.json` (`{ "verifyAttestation": true }`) or per-invocation via `--verify-attestation`. Requires `gh` installed and authenticated. |

**Config-only settings (`~/.plannotator/config.json`)**: Some settings have no env-var equivalent and are toggled by editing the config file directly:

- `pfmReminder` (`true` / `false`, default `false`) — when enabled, a Plannotator Flavored Markdown reminder is injected at plan-time describing the renderer's extensions (code-file links, callouts, tables, diagrams, task lists, hex swatches, wiki-links). Lets the planning agent enrich plans with PFM features without having to discover them. Composes cleanly with the compound-skill improvement hook. Supported across all three runtimes: Claude Code (`improve-context` PreToolUse hook in `apps/hook/server/index.ts`), OpenCode (`experimental.chat.system.transform` in `apps/opencode-plugin/index.ts`), and Pi (`before_agent_start` in `apps/pi-extension/index.ts`).

**Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected when `PLANNOTATOR_REMOTE` is unset. Set `PLANNOTATOR_REMOTE=1` / `true` to force remote mode or `0` / `false` to force local mode.

**Devcontainer/SSH usage:**
Expand Down
27 changes: 14 additions & 13 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLa
import { writeRemoteShareLink } from "@plannotator/server/share-url";
import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file";
import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common";
import { statSync, rmSync, realpathSync, existsSync } from "fs";
import { statSync, rmSync, realpathSync, existsSync, appendFileSync } from "fs";
import { parseRemoteUrl } from "@plannotator/shared/repo";
import {
getReviewApprovedPrompt,
Expand All @@ -89,6 +89,7 @@ import { openBrowser } from "@plannotator/server/browser";
import { detectProjectName } from "@plannotator/server/project";
import { hostnameOrFallback } from "@plannotator/shared/project";
import { readImprovementHook } from "@plannotator/shared/improvement-hooks";
import { composeImproveContext } from "@plannotator/shared/pfm-reminder";
import { AGENT_CONFIG, type Origin } from "@plannotator/shared/agents";
import {
findSessionLogsByAncestorWalk,
Expand All @@ -107,7 +108,7 @@ import {
isTopLevelHelpInvocation,
} from "./cli";
import path from "path";
import { tmpdir } from "os";
import { tmpdir, homedir } from "os";

// Embed the built HTML at compile time
// @ts-ignore - Bun import attribute for text
Expand Down Expand Up @@ -1048,21 +1049,21 @@ if (args[0] === "sessions") {
// ============================================
//
// Called by PreToolUse hook on EnterPlanMode.
// Reads the improvement hook file and returns additionalContext.
// No file = exit 0 silently (passthrough).
// Composes any enabled context sources (compound improvement hook,
// PFM reminder) into a single additionalContext payload.
// Nothing enabled = exit 0 silently (passthrough).

// Must consume stdin (Claude Code hooks deliver event JSON on stdin)
await Bun.stdin.text();

const hook = readImprovementHook("enterplanmode-improve");
if (!hook) process.exit(0);

const context = [
"[Plannotator Improvement Hook]",
"The following corrective instructions were generated from analysis of previous plan denial patterns.",
"Apply these guidelines when writing your plan:\n",
hook.content,
].join("\n");
const pfmEnabled = loadConfig().pfmReminder === true;

const context = composeImproveContext({
pfmEnabled,
improvementHookContent: hook?.content ?? null,
});

if (context === null) process.exit(0);

console.log(JSON.stringify({
hookSpecificOutput: {
Expand Down
18 changes: 17 additions & 1 deletion apps/opencode-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ import {
buildPlanFileRule,
getAnnotateMessageFeedbackPrompt,
} from "@plannotator/shared/prompts";
import { loadConfig } from "@plannotator/shared/config";
import { readImprovementHook } from "@plannotator/shared/improvement-hooks";
import { composeImproveContext } from "@plannotator/shared/pfm-reminder";
import {
stripConflictingPlanModeRules,
} from "./plan-mode";
Expand Down Expand Up @@ -351,8 +354,21 @@ tools (except writing markdown files), or otherwise make changes to the system.
}

if (shouldInjectFullPlanningPrompt(lastUserAgent, workflowOptions)) {
output.system = stripConflictingPlanModeRules(output.system);
const stripped = stripConflictingPlanModeRules(output.system);
output.system.length = 0;
output.system.push(...stripped);
output.system.push(getPlanningPrompt());

const hook = readImprovementHook("enterplanmode-improve");
const pfmEnabled = loadConfig().pfmReminder === true;
const improveContext = composeImproveContext({
pfmEnabled,
improvementHookContent: hook?.content ?? null,
});
if (improveContext) {
output.system.push(improveContext);
}

return;
}

Expand Down
17 changes: 15 additions & 2 deletions apps/pi-extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import { FILE_BROWSER_EXCLUDED } from "./generated/reference-common.js";
import { htmlToMarkdown } from "./generated/html-to-markdown.js";
import { urlToMarkdown, isConvertedSource } from "./generated/url-to-markdown.js";
import { loadConfig, resolveUseJina } from "./generated/config.js";
import { readImprovementHook } from "./generated/improvement-hooks.js";
import { composeImproveContext } from "./generated/pfm-reminder.js";
import {
getReviewApprovedPrompt,
getReviewDeniedSuffix,
Expand Down Expand Up @@ -981,6 +983,17 @@ export default function plannotator(pi: ExtensionAPI): void {
}

const todoStats = phase === "executing" ? formatTodoList(checklistItems) : formatTodoList([]);

let improveContext: string | null = null;
if (phase === "planning") {
const hook = readImprovementHook("enterplanmode-improve");
const pfmEnabled = loadConfig().pfmReminder === true;
improveContext = composeImproveContext({
pfmEnabled,
improvementHookContent: hook?.content ?? null,
});
}

if (profile?.systemPrompt) {
const rendered = renderTemplate(
profile.systemPrompt,
Expand All @@ -1000,7 +1013,7 @@ export default function plannotator(pi: ExtensionAPI): void {
);
}

return { systemPrompt: rendered.text };
return { systemPrompt: rendered.text + (improveContext ? "\n\n" + improveContext : "") };
}

if (phase === "planning") {
Expand Down Expand Up @@ -1072,7 +1085,7 @@ Your turn should only end by either:
- Asking the user a question to gather more information.
- Calling ${PLAN_SUBMIT_TOOL} when the plan is ready for review.

Do not end your turn without doing one of these two things.`,
Do not end your turn without doing one of these two things.` + (improveContext ? "\n\n---\n\n" + improveContext : ""),
display: false,
},
};
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 @@ -6,7 +6,7 @@ cd "$(dirname "$0")"

mkdir -p generated generated/ai/providers

for f in feedback-templates prompts review-core jj-core vcs-core review-args storage draft project pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown url-to-markdown tour annotate-args at-reference; do
for f in feedback-templates prompts review-core jj-core vcs-core review-args storage draft project pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown url-to-markdown tour annotate-args at-reference pfm-reminder improvement-hooks; 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
7 changes: 7 additions & 0 deletions packages/shared/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ export interface PlannotatorConfig {
* Set to false to always use plain fetch + Turndown.
*/
jina?: boolean;
/**
* Inject a Plannotator Flavored Markdown reminder into every EnterPlanMode
* call so the agent is aware it can enrich plans with code-file links,
* callouts, tables, diagrams, task lists, and the other PFM extensions.
* Read by the `improve-context` PreToolUse handler. Default: false.
*/
pfmReminder?: boolean;
}

const CONFIG_DIR = join(homedir(), ".plannotator");
Expand Down
1 change: 1 addition & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"./config": "./config.ts",
"./prompts": "./prompts.ts",
"./improvement-hooks": "./improvement-hooks.ts",
"./pfm-reminder": "./pfm-reminder.ts",
"./worktree": "./worktree.ts",
"./worktree-pool": "./worktree-pool.ts",
"./html-to-markdown": "./html-to-markdown.ts",
Expand Down
88 changes: 88 additions & 0 deletions packages/shared/pfm-reminder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Tests for the PFM reminder constant and improve-context composer.
*
* Run: bun test packages/shared/pfm-reminder.test.ts
*/

import { describe, expect, test } from "bun:test";
import { PFM_REMINDER, composeImproveContext } from "./pfm-reminder";

describe("PFM_REMINDER", () => {
test("identifies itself with a recognizable header", () => {
expect(PFM_REMINDER).toContain("[Plannotator Flavored Markdown]");
});

test("covers the headline PFM features the renderer actually supports", () => {
// If any of these features moves out of the renderer, update both the
// reminder and this test together.
expect(PFM_REMINDER).toContain("Code-file links");
expect(PFM_REMINDER).toContain("> [!NOTE]");
expect(PFM_REMINDER).toContain("> [!TIP]");
expect(PFM_REMINDER).toContain("> [!WARNING]");
expect(PFM_REMINDER).toContain(":::tip");
expect(PFM_REMINDER).toContain("Tables");
expect(PFM_REMINDER).toContain("Task lists");
expect(PFM_REMINDER).toContain("Diagrams");
expect(PFM_REMINDER).toContain("mermaid");
expect(PFM_REMINDER).toContain("Wiki-links");
expect(PFM_REMINDER).toContain("Hex color swatches");
});

test("stays small enough to inject on every EnterPlanMode call", () => {
// Soft cap so the reminder doesn't drift into a tutorial. Bump if intentional.
expect(PFM_REMINDER.length).toBeLessThan(3000);
});
});

describe("composeImproveContext", () => {
test("returns null when nothing is enabled", () => {
expect(
composeImproveContext({ pfmEnabled: false, improvementHookContent: null }),
).toBeNull();
});

test("treats empty improvement-hook content the same as null", () => {
expect(
composeImproveContext({ pfmEnabled: false, improvementHookContent: "" }),
).toBeNull();
});

test("returns just the PFM reminder when only PFM is enabled", () => {
const ctx = composeImproveContext({
pfmEnabled: true,
improvementHookContent: null,
});
expect(ctx).not.toBeNull();
expect(ctx).toContain("[Plannotator Flavored Markdown]");
expect(ctx).not.toContain("[Plannotator Improvement Hook]");
});

test("returns just the improvement-hook block when only it is set (legacy behavior)", () => {
const ctx = composeImproveContext({
pfmEnabled: false,
improvementHookContent: "1. Always include a test plan section.",
});
expect(ctx).not.toBeNull();
expect(ctx).toContain("[Plannotator Improvement Hook]");
expect(ctx).toContain("The following corrective instructions were generated");
expect(ctx).toContain("1. Always include a test plan section.");
expect(ctx).not.toContain("[Plannotator Flavored Markdown]");
});

test("composes both with PFM reminder first, separated by a divider", () => {
const ctx = composeImproveContext({
pfmEnabled: true,
improvementHookContent: "1. Always include a test plan section.",
})!;

const pfmIdx = ctx.indexOf("[Plannotator Flavored Markdown]");
const improveIdx = ctx.indexOf("[Plannotator Improvement Hook]");
expect(pfmIdx).toBeGreaterThanOrEqual(0);
expect(improveIdx).toBeGreaterThan(pfmIdx);

// A horizontal rule separates the two sections so the agent reads them
// as distinct payloads.
const between = ctx.slice(pfmIdx, improveIdx);
expect(between).toContain("\n---\n");
});
});
79 changes: 79 additions & 0 deletions packages/shared/pfm-reminder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Plannotator Flavored Markdown reminder.
*
* Static prose injected into the EnterPlanMode PreToolUse hook when the user
* has opted in via `pfmReminder: true` in ~/.plannotator/config.json. It tells
* the planning agent which markdown extensions Plannotator's viewer renders so
* plans can be enriched with code-file links, callouts, tables, diagrams, etc.
*
* Keep this short. The agent reads it on every EnterPlanMode call.
*/

/**
* Compose the additionalContext string for the improve-context PreToolUse handler.
* Returns null when no sources are enabled (handler should exit silently).
*
* Order: PFM reminder first (capabilities) then improvement hook (corrective
* rules). Both appear separated by a horizontal rule.
*/
export function composeImproveContext(input: {
pfmEnabled: boolean;
improvementHookContent: string | null;
}): string | null {
const sections: string[] = [];

if (input.pfmEnabled) {
sections.push(PFM_REMINDER);
}

if (input.improvementHookContent) {
sections.push([
"[Plannotator Improvement Hook]",
"The following corrective instructions were generated from analysis of previous plan denial patterns.",
"Apply these guidelines when writing your plan:\n",
input.improvementHookContent,
].join("\n"));
}

if (sections.length === 0) return null;
return sections.join("\n\n---\n\n");
}

export const PFM_REMINDER = `[Plannotator Flavored Markdown]
This plan will be reviewed in Plannotator, which renders GitHub Flavored Markdown plus the extensions below. Use these features when they make the plan clearer for the reviewer — don't force them in for their own sake.

Code-file links (highest leverage)
Reference real source files inline. Plannotator validates the path and renders a clickable badge that opens the file in the reviewer's editor — prefer this over pasting code when you just need to point at something.
\`packages/server/index.ts\` backticked path
\`packages/server/index.ts:42\` path with line number
[the handler](packages/server/index.ts:42) markdown link form
Ambiguous paths (e.g. \`index.ts\`) still render and open a picker.

Callouts and alerts
GitHub-style alerts highlight critical context:
> [!NOTE] general callout
> [!TIP] suggestion
> [!WARNING] watch out
> [!CAUTION] risk
> [!IMPORTANT] must-read
Or use directive containers for richer blocks:
:::tip
Body with **inline markdown**.
:::

Tables
Pipe tables are interactive — the reviewer can copy as Markdown/CSV from a hover toolbar, or expand to a sortable, filterable popout. Reach for them for comparisons, files-to-change lists, or risk summaries.

Task lists
Use \`- [ ]\` and \`- [x]\` for actionable steps the reviewer (or a follow-up agent) can tick off.

Diagrams
Code fences with \`mermaid\` or \`graphviz\` render as live diagrams. Useful for flow, state, sequence, or architecture sketches.

Other extras
- Wiki-links: [[architecture]] auto-resolves to .md docs in the workspace
- Hex color swatches: #1a2bcc renders as a small color chip
- @username and #123 link to GitHub when a repo is detected
- A small set of emoji shortcodes is supported (:rocket:, :warning:, :white_check_mark:, ...)

Plain prose is always fine. The point is: prefer code-file links over copy-pasted snippets, and reach for callouts or tables when structure makes the plan easier to scan.`;
12 changes: 12 additions & 0 deletions scripts/install.cmd
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,18 @@ if exist "!PLUGIN_HOOKS!" (
(
echo {
echo "hooks": {
echo "PreToolUse": [
echo {
echo "matcher": "EnterPlanMode",
echo "hooks": [
echo {
echo "type": "command",
echo "command": "!EXE_PATH! improve-context",
echo "timeout": 5
echo }
echo ]
echo }
echo ],
echo "PermissionRequest": [
echo {
echo "matcher": "ExitPlanMode",
Expand Down
12 changes: 12 additions & 0 deletions scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,18 @@ if (Test-Path $pluginHooks) {
@"
{
"hooks": {
"PreToolUse": [
{
"matcher": "EnterPlanMode",
"hooks": [
{
"type": "command",
"command": "$exePathJson improve-context",
"timeout": 5
}
]
}
],
"PermissionRequest": [
{
"matcher": "ExitPlanMode",
Expand Down
Loading
Loading