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
322 changes: 281 additions & 41 deletions AGENTS.md

Large diffs are not rendered by default.

15 changes: 10 additions & 5 deletions apps/hook/server/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ export function formatTopLevelHelp(): string {
" plannotator guide share --id <savedGuideId> | --guide <guide.json> --patch <diff.patch> | --snapshot <snapshot.json> [--public] [--ttl <7d>] [--json]",
" plannotator guide unshare <id> --token <deleteToken>",
" plannotator sessions",
" plannotator inbox [--background | --no-open]",
" plannotator inbox mcp",
" plannotator uninstall [--purge] [--yes] [--dry-run]",
" plannotator improve-context",
"",
Expand All @@ -178,9 +180,9 @@ export function formatTopLevelHelp(): string {
* To launch one, delete it from this set, add its usage lines to
* formatTopLevelHelp() and document it in apps/skills/core/plannotator/SKILL.md.
*
* `inbox`: the Plannotator Inbox, hidden until its window ships.
* Empty: `inbox` (the Plannotator Inbox) was the last one, and launched.
*/
export const HIDDEN_SUBCOMMANDS: ReadonlySet<string> = new Set(["inbox"]);
export const HIDDEN_SUBCOMMANDS: ReadonlySet<string> = new Set<string>();

// Per-subcommand usage text. Keyed by the canonical subcommand token; aliases
// (e.g. `last` → `annotate-last`) are resolved in formatSubcommandHelp().
Expand Down Expand Up @@ -342,9 +344,11 @@ export const SUBCOMMAND_HELP: Record<string, string> = {
" plannotator inbox --background Start it detached without opening a browser,",
" print its URL and exit (what agents run)",
" plannotator inbox --no-open Run it here without opening a browser",
" plannotator inbox mcp Stdio MCP server for agents: send_message,",
" read_thread, resolve_message, wait_for_reply.",
" Starts a stopped Inbox (no browser tab).",
" plannotator inbox mcp Stdio MCP server for agents: messages, questions,",
" files, decisions and guided reviews (send_message,",
" read_thread, wait_for_reply, resolve_message,",
" list_decisions, record_decision, get_guide_brief,",
" submit_guide). Starts a stopped Inbox (no browser tab).",
"",
"Register the MCP entry with an agent, e.g.:",
" claude mcp add plannotator-inbox -- plannotator inbox mcp",
Expand Down Expand Up @@ -402,6 +406,7 @@ export function formatInteractiveNoArgClarification(): string {
" plannotator last",
" plannotator archive",
" plannotator sessions",
" plannotator inbox",
" plannotator uninstall",
"",
"Run 'plannotator --help' for top-level usage.",
Expand Down
56 changes: 37 additions & 19 deletions apps/hook/server/plannotator-skill-reference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
* - Subcommands: BIDIRECTIONAL. Every subcommand the skill documents must
* exist in the CLI, and every user-facing CLI subcommand must appear in
* the skill. A renamed, removed, or newly added subcommand fails here.
* Unreleased subcommands (HIDDEN_SUBCOMMANDS in cli.ts) are the one
* exception, and the rule flips for them: they must NOT be in the skill
* or the top-level help until they are taken out of that set.
* Unreleased subcommands (HIDDEN_SUBCOMMANDS in cli.ts, empty today)
* are the one exception, and the rule flips for them: they must NOT be
* in the skill or the top-level help until they are taken out of that
* set.
* - Flags: ONE-DIRECTIONAL. Every flag the skill mentions must be accepted
* somewhere in the CLI, so a renamed or deleted flag fails. The reverse
* is NOT asserted: a new CLI flag that the skill never mentions passes.
Expand Down Expand Up @@ -236,25 +237,42 @@ describe("plannotator knowledge skill freshness", () => {
}
});

// Every way a hidden subcommand can leak, for one name. Separate from the
// test so the check can be driven with a set other than HIDDEN_SUBCOMMANDS,
// which is empty while nothing is unreleased.
function hiddenSubcommandLeaks(sub: string): string[] {
const leaks: string[] = [];
if (!(sub in SUBCOMMAND_HELP)) {
leaks.push(`HIDDEN_SUBCOMMANDS names \`${sub}\`, which has no SUBCOMMAND_HELP entry — remove it from the set`);
}
if (documentedSubcommands.has(sub) || skillDoc.toLowerCase().includes(`plannotator ${sub}`)) {
leaks.push(`\`plannotator ${sub}\` is unreleased (HIDDEN_SUBCOMMANDS in cli.ts) but apps/skills/core/plannotator/SKILL.md documents it`);
}
if (formatTopLevelHelp().includes(`plannotator ${sub}`)) {
leaks.push(`\`plannotator ${sub}\` is unreleased (HIDDEN_SUBCOMMANDS in cli.ts) but \`plannotator --help\` lists it`);
}
return leaks;
}

test("unreleased subcommands stay out of the skill and the top-level help", () => {
// What regresses: someone documents a hidden subcommand (the Inbox before
// its window ships) and agents start running it, or it leaks into
// `plannotator --help`. A hidden entry must also still be a real
// subcommand, so a stale name in the set cannot make this pass vacuously.
const topLevel = formatTopLevelHelp();
const skillText = skillDoc.toLowerCase();
// What regresses: someone documents a hidden subcommand and agents start
// running it, or it leaks into `plannotator --help`. A hidden entry must
// also still be a real subcommand, so a stale name in the set cannot make
// this pass vacuously.
for (const sub of HIDDEN_SUBCOMMANDS) {
expect(
sub in SUBCOMMAND_HELP,
`HIDDEN_SUBCOMMANDS names \`${sub}\`, which has no SUBCOMMAND_HELP entry — remove it from the set`,
).toBe(true);
expect(
documentedSubcommands.has(sub),
`\`plannotator ${sub}\` is unreleased (HIDDEN_SUBCOMMANDS in cli.ts) but apps/skills/core/plannotator/SKILL.md documents it`,
).toBe(false);
expect(skillText).not.toContain(`plannotator ${sub}`);
expect(topLevel).not.toContain(`plannotator ${sub}`);
expect(hiddenSubcommandLeaks(sub)).toEqual([]);
}
// The check itself must still bite: a released, documented subcommand
// reads as leaked in both places, and a name with no help entry as stale.
expect(hiddenSubcommandLeaks("review")).toHaveLength(2);
expect(hiddenSubcommandLeaks("no-such-subcommand")).toHaveLength(1);
});

test("the Inbox is released: in the top-level help and the skill", () => {
// It was the hidden subcommand until launch; nothing hides it now.
expect(HIDDEN_SUBCOMMANDS.has("inbox")).toBe(false);
expect(formatTopLevelHelp()).toContain("plannotator inbox mcp");
expect(documentedSubcommands.has("inbox")).toBe(true);
});
});

Expand Down
19 changes: 13 additions & 6 deletions apps/hook/server/unknown-subcommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,20 @@ describe("unknown subcommand", () => {
expect(findClosestSubcommand("x".repeat(10_000))).toBeNull();
});

test("an unreleased subcommand runs but is never suggested", () => {
// `inbox` is in HIDDEN_SUBCOMMANDS: a typo must not advertise it, while
// the real command still dispatches (it is not "Unknown command").
test("an unreleased subcommand is never suggested", () => {
// HIDDEN_SUBCOMMANDS is empty now that the Inbox launched, so the filter
// is driven with its own set: a hidden name must not be advertised by a
// typo, while the same typo finds it once it is released.
const hidden = new Set(["inbox"]);
expect(findClosestSubcommand("inbo", hidden)).toBeNull();
expect(findClosestSubcommand("inbx", hidden)).toBeNull();
expect(findClosestSubcommand("inbo")).toBe("inbox");
expect(findClosestSubcommand("inbx")).toBe("inbox");
});

test("the launched Inbox dispatches and is suggested", () => {
expect(findUnknownSubcommand(["inbox", "--background"])).toBeNull();
expect(findClosestSubcommand("inbo")).toBeNull();
expect(findClosestSubcommand("inbx")).toBeNull();
expect(formatUnknownSubcommandError("inbo")).not.toContain("inbox");
expect(formatUnknownSubcommandError("inbo")).toContain("plannotator inbox");
});

test("error text names the typo and points at --help", () => {
Expand Down
21 changes: 15 additions & 6 deletions apps/hook/server/unknown-subcommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ const PUBLISHED_SUBCOMMANDS = [
];

// Unreleased subcommands still run, but are never suggested to a user.
const SUGGESTABLE_SUBCOMMANDS = PUBLISHED_SUBCOMMANDS.filter(
(sub) => !HIDDEN_SUBCOMMANDS.has(sub),
);
function suggestableSubcommands(hidden: ReadonlySet<string>): string[] {
return PUBLISHED_SUBCOMMANDS.filter((sub) => !hidden.has(sub));
}

export const KNOWN_SUBCOMMANDS: ReadonlySet<string> = new Set([
...PUBLISHED_SUBCOMMANDS,
Expand All @@ -48,12 +48,21 @@ function levenshteinDistance(left: string, right: string): number {
return previous[right.length];
}

export function findClosestSubcommand(token: string): string | null {
/**
* The closest published subcommand to a typo, never one in `hidden`
* (HIDDEN_SUBCOMMANDS by default; tests pass their own set because the real
* one is empty while nothing is unreleased).
*/
export function findClosestSubcommand(
token: string,
hidden: ReadonlySet<string> = HIDDEN_SUBCOMMANDS,
): string | null {
const normalized = token.toLowerCase();
if (!normalized) return null;
const candidates = suggestableSubcommands(hidden);

if (normalized.length >= 3) {
const prefixMatch = SUGGESTABLE_SUBCOMMANDS.find((candidate) =>
const prefixMatch = candidates.find((candidate) =>
candidate.startsWith(normalized),
);
if (prefixMatch) return prefixMatch;
Expand All @@ -64,7 +73,7 @@ export function findClosestSubcommand(token: string): string | null {
let closest: string | null = null;
let closestDistance = Infinity;

for (const candidate of SUGGESTABLE_SUBCOMMANDS) {
for (const candidate of candidates) {
if (Math.abs(normalized.length - candidate.length) > tolerance) continue;

const distance = levenshteinDistance(normalized, candidate);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ Plannotator data too, use:
plannotator uninstall --purge
```

Purge requires typing `purge` at the prompt. The CLI warns that this data is
Purge also stops a running [Inbox](/docs/reference/inbox/) before it removes
the Inbox's data. Purge requires typing `purge` at the prompt. The CLI warns that this data is
local-only: it is not stored on a Plannotator server and cannot be recovered
after purge. `--yes` (or `-y`) skips confirmation for automation, and is
required when no interactive terminal is available. `--dry-run` previews the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ All Plannotator environment variables and their defaults.
|----------|---------|-------------|
| `PLANNOTATOR_CLAUDE_MOD` | on | Controls the [Plannotator mod](/docs/guides/claude-code/#the-plannotator-mod) for Claude Code 2.1.287 or newer: plan review, code review, annotate and annotate-last without making Claude wait, the `plannotator` tool, and Ask this session. Set to `0`, `false`, `off` or `disabled` to turn it off and use the classic hook. `1`, `true` or `on` turn it on; an empty or other value counts as unset (on). Claude Code reads it when it starts, so restart after a change. Set it in the shell that starts Claude Code or in the `env` block of Claude Code's `settings.json`. Can also be set via `~/.plannotator/config.json` (`{ "claudeCodeMod": false }`); the env var takes precedence. The mod always stays off in `claude -p` and SDK runs and on Windows. |
| `PLANNOTATOR_AGENT_TOOL` | on for Claude Code, off on Pi and OpenCode 2 | Controls the `plannotator` tool the agent uses to open Plannotator itself, on Claude Code (with the mod), Pi and OpenCode 2. Set to `1`, `true` or `on` to give the agent the tool; `0`, `false`, `off` or `disabled` keep it out of the agent's tool list; an empty or other value counts as unset (the host's default). Only the tool is affected: the slash commands, plan review and Ask this session work as before. It is read when a session starts and never during one, because changing the tool list mid-session would invalidate the model's prompt cache: restart Claude Code or OpenCode, or start a new Pi session (or `/reload`), after a change. Can also be set via `~/.plannotator/config.json` (`{ "agentTool": true }` or `false`); the env var takes precedence. |
| `PLANNOTATOR_INBOX_TOOL` | on for Claude Code, off on Pi and OpenCode | Controls the `plannotator_inbox` tool and the reply wake of the [Plannotator Inbox](/docs/reference/inbox/) on Claude Code (with the mod), Pi and OpenCode. The tool is only ever added where you have run the Inbox once. Set to `1`, `true` or `on` to add it on every host; `0`, `false`, `off` or `disabled` keep it out; an empty or other value counts as unset (the host's default). Read when a session starts and never during one: restart Claude Code or OpenCode, or start a new Pi session (or `/reload`), after a change. Can also be set via `~/.plannotator/config.json` (`{ "inboxTool": true }`, or per host: `{ "inboxTool": { "pi": true } }`), which the Inbox's Settings writes; the env var takes precedence. Independent of `PLANNOTATOR_AGENT_TOOL`. |
| `PLANNOTATOR_MOD_DEBUG` | off | Set to `1` before you start Claude Code to make the Plannotator mod write a debug log to `~/.plannotator/claude-code-mod/debug.log` (or under `PLANNOTATOR_DATA_DIR`). Useful when you report a problem with the mod. |

## Glimpse (native window)
Expand Down
Loading
Loading