diff --git a/AGENTS.md b/AGENTS.md index fcc671c..e2ba94e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,11 +52,11 @@ Shared assets (skills, agents, hook scripts) are consumed by both hosts; host-sp - **Agents**: `agents/pgsql-reviewer.md` (SQL review) and `agents/pgsql-plan-analyst.md` (`EXPLAIN` analysis). - **Cursor rule**: `rules/pgsql-context.mdc` (`globs: ["**/*.sql"]`), mirroring the router. - **Claude hooks**: `hooks/hooks.json` wires `session-start.sh` (`SessionStart`) and `lint-on-save.sh` + `skill-nudge.sh` (`PostToolUse`, `matcher: "Write|Edit"`), with `${CLAUDE_PLUGIN_ROOT}` paths. -- **Cursor hooks**: `hooks/cursor-hooks.json` (`"version": 1`) wires the same scripts to `sessionStart` and to `postToolUse` with matcher `Write`, with **workspace-relative** commands; Cursor runs a plugin hook from the plugin's install directory and sets `CURSOR_PLUGIN_ROOT` and `CURSOR_PROJECT_DIR`. +- **Cursor hooks**: `hooks/cursor-hooks.json` (`"version": 1`) wires the same scripts to `sessionStart` and to `postToolUse` with matcher `Write|StrReplace`. Commands are relative to the plugin install (`bash hooks/session-start.sh`), where Cursor runs a plugin hook; it sets `CURSOR_PLUGIN_ROOT` and `CURSOR_PROJECT_DIR`. - **Shared hook scripts** (host-agnostic; all exit 0 always): - - `hooks/session-start.sh`: detects PostgreSQL use (pgx or lib/pq in `go.mod`, `ext-pdo_pgsql` in `composer.json`, a postgres image in a compose file, or a `*.sql` file) and prints one line naming the floor, the skills and the reviewer; under Cursor it inspects `CURSOR_PROJECT_DIR` and prints the line as JSON `additional_context`. + - `hooks/session-start.sh`: detects PostgreSQL use (pgx or lib/pq in `go.mod`, `ext-pdo_pgsql` in `composer.json`, a postgres image in a compose file, or a `*.sql` file) and prints one line naming the floor, the skills and the reviewer; under Cursor it inspects `CURSOR_PROJECT_DIR` and prints the line, with the skills' path in the plugin install, as JSON `additional_context`. - `hooks/lint-on-save.sh`: after a `*.sql` edit, runs `squawk --reporter gcc` on that file when squawk is on PATH and a `.squawk.toml` exists in the file's directory or a parent; findings reach the model as `hookSpecificOutput.additionalContext` (Claude Code) or `additional_context` (Cursor). - - `hooks/skill-nudge.sh`: after a `.sql`, `.go` or `.php` edit, names ONE focused skill matching the text the edit adds (Claude's `new_string` or `content`; Cursor documents no `Write` input, so there it matches the file), once per skill per session (`session_id` or Cursor's `conversation_id`). Go and PHP edits nudge only on unmistakable SQL. + - `hooks/skill-nudge.sh`: after a `.sql`, `.go` or `.php` edit, names ONE focused skill matching the text the edit adds (Claude's `new_string` or `content`, or Cursor's `StrReplace` `new_string`; a Cursor `Write` with no added-text field matches the file), once per skill per session (`session_id` or Cursor's `conversation_id`). Claude is told `pgsql-coding:`; Cursor is told the skill's `SKILL.md` path in the plugin install. Go and PHP edits nudge only on unmistakable SQL. - **References**: top-level `references/squawk.toml`, the shipped squawk config; the `/pgsql-lint-setup` scaffold block must stay byte-identical to it. - **Evals**: `evals//` with `prompt.md` and `graders/`, run by `claude plugin eval`. Results go to `evals/results/`, which is gitignored. - **Scripts**: `scripts/validate.sh` (wraps the stdlib-only `scripts/validate.py`), `scripts/hooks-test.sh`, `scripts/plan-summary-test.py`. @@ -69,7 +69,7 @@ Shared assets (skills, agents, hook scripts) are consumed by both hosts; host-sp |------|---------| | Skills | `pgsql-coding` (auto-invoked router, with `versions.md`) + the focused, load-on-use `pgsql-query`, `pgsql-jsonb`, `pgsql-schema`, `pgsql-indexing`, `pgsql-performance`, `pgsql-migrations`, each with its `references/` | | Slash command (user-invoked skill) | `/pgsql-lint-setup` (write the reference `.squawk.toml`) | -| Agents | `pgsql-reviewer`: context-isolated, report-only reviewer of SQL in `.sql` files, migrations, and Go or PHP query strings. `pgsql-plan-analyst`: context-isolated, report-only analyst of a long `EXPLAIN` plan or a before-and-after pair; runs the plan summariser and reads only the flagged nodes. Both load the skills with the `Skill` tool, never edit, never run DML, dispatch no sub-agents, and use `tools:` not `allowed-tools:` | +| Agents | `pgsql-reviewer`: context-isolated, report-only reviewer of SQL in `.sql` files, migrations, and Go or PHP query strings. `pgsql-plan-analyst`: context-isolated, report-only analyst of a long `EXPLAIN` plan or a before-and-after pair; runs the plan summariser and reads only the flagged nodes. Both read the skills (Claude Code: the Skill tool; Cursor: each `SKILL.md` in the plugin install), never edit, never run DML, and dispatch no sub-agents. Both declare `tools:`, never `allowed-tools:`, and `permissionMode: readonly`; what each host enforces: [docs/authoring.md](docs/authoring.md) | | Cursor rule | `rules/pgsql-context.mdc`, scoped to `**/*.sql`, mirroring the router | | Hooks | `session-start`, `lint-on-save`, `skill-nudge` (see Repository Layout) | @@ -127,7 +127,7 @@ When adding or renaming a component, update in lockstep: the router's two tables - **Agents use `tools:`, not `allowed-tools:`.** In an agent file `allowed-tools:` is ignored and the agent silently inherits *all* tools. The validator flags it. - **`EXPLAIN ANALYZE` executes the statement.** Every component that suggests running SQL says so, wraps DML in `BEGIN ... ROLLBACK`, and asks before touching any database the user did not name. Keep that wording when editing. - **Keep the two manifests in parity.** Cursor needs explicit path keys; Claude relies on default-folder discovery. -- **The Cursor hook uses a workspace-relative command** (`bash hooks/session-start.sh`), *not* `${CLAUDE_PLUGIN_ROOT}` (a Claude-Code-only variable). Don't "fix" it. +- **The Cursor hook command is relative to the plugin install** (`bash hooks/session-start.sh`), *not* `${CLAUDE_PLUGIN_ROOT}` (a Claude-Code-only variable) and *not* the user's repository. Don't "fix" it. - **Every hook script must be wired on both hosts**, exist, and be executable; the validator checks all three. - **`lint-on-save` is opt-in by the project.** It runs only where a `.squawk.toml` exists; squawk's rules target migrations, so linting every `.sql` file in every repository would be noise. - **The squawk config has two copies**: `references/squawk.toml` and the block in `/pgsql-lint-setup`. Change both; the validator compares them byte for byte. diff --git a/README.md b/README.md index bbe1751..7f7abbc 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,8 @@ See [docs/install.md](docs/install.md) for marketplace, local-development, updat | Skill `pgsql-performance` | Reading `EXPLAIN` plans, planner statistics, generic vs custom plans, `pg_stat_statements`, `auto_explain`, waits and locks, and the rewrites the evidence justifies. Ships `scripts/plan-summary.py`, which summarises a TEXT or JSON plan. | | Skill `pgsql-migrations` | Changing a live schema safely: lock levels, `lock_timeout`, `CONCURRENTLY`, `NOT VALID` then `VALIDATE`, expand and contract, batched backfills, each mapped to its squawk rule. | | Skill `/pgsql-lint-setup` | User-invoked: writes the reference `.squawk.toml` and says how to run squawk locally and in CI. Never overwrites an existing config unprompted. | -| Agent `pgsql-reviewer` | Report-only, context-isolated SQL reviewer. Loads the skills, returns severity-ranked findings, never edits files or runs DML, and dispatches no sub-agents. | -| Agent `pgsql-plan-analyst` | Report-only, context-isolated `EXPLAIN` analyst for a long plan or a before-and-after pair. Runs the plan summariser, reads only the flagged nodes, and returns where the time goes, where an estimate error starts, and the command that confirms each fix. | +| Agent `pgsql-reviewer` | Report-only, context-isolated SQL reviewer. Reads the skills, returns severity-ranked findings, never edits files or runs DML, and dispatches no sub-agents. | +| Agent `pgsql-plan-analyst` | Report-only, context-isolated `EXPLAIN` analyst for a long plan or a before-and-after pair. Runs `scripts/plan-summary.py` from the `pgsql-performance` skill directory, reads only the flagged nodes, and returns where the time goes, where an estimate error starts, and the command that confirms each fix. | | Session-start hook | Detects PostgreSQL use (pgx or lib/pq in `go.mod`, `ext-pdo_pgsql` in `composer.json`, a postgres image in a compose file, or a `*.sql` file) and prints one standards line; dual-host. | | Lint-on-save hook | After a `*.sql` edit, runs squawk on that file when the project has a `.squawk.toml` and squawk is installed, and hands the findings to the model; dual-host (Cursor: [install.md](docs/install.md#hooks)); never blocks an edit. | | Skill-nudge hook | After a `.sql`, `.go` or `.php` edit, names the one focused skill the edit calls for, once per skill per session; dual-host (Cursor: [install.md](docs/install.md#hooks)). | @@ -78,8 +78,8 @@ SQL usually lives inside Go or PHP code, so a change often needs two plugins. Th Subagents do not inherit the parent session's skills. A plan runner that dispatches implementers and reviewers must tell them, in every brief, to load the skills: -- **Implementer brief**: "Before writing SQL, invoke the Skill tool with `pgsql-coding:pgsql-coding`, then the focused skills matching the change (see its *Route, then load* table). Run squawk on migration files. Run `EXPLAIN` only against the database named here: ." -- **Reviewer brief**: "Before reading the diff, load `pgsql-coding:pgsql-coding` plus the focused skills the diff calls for; cite the rule a finding rests on. Do not dispatch `pgsql-reviewer`: you are the review seat." +- **Implementer brief**: "Before writing SQL, load the standards. On Claude Code, invoke the Skill tool with `pgsql-coding:pgsql-coding`, then the focused skills matching the change (see its *Route, then load* table). On Cursor, read `/skills/pgsql-coding/SKILL.md`, then the focused skills' `SKILL.md` beside it (the session-start line prints the plugin root). Run squawk on migration files. Run `EXPLAIN` only against the database named here: ." +- **Reviewer brief**: "Before reading the diff, read the pgsql-coding skills the diff calls for. On Claude Code, invoke the Skill tool with `pgsql-coding:pgsql-coding`. On Cursor, read each skill's `SKILL.md` under `/skills/` (the session-start line prints the plugin root). Cite the rule a finding rests on. Do not dispatch `pgsql-reviewer`: you are the review seat." Use `pgsql-reviewer` directly when no such seat exists, as with an ad-hoc "review this migration" request. diff --git a/agents/pgsql-plan-analyst.md b/agents/pgsql-plan-analyst.md index 8f778dc..c1349ed 100644 --- a/agents/pgsql-plan-analyst.md +++ b/agents/pgsql-plan-analyst.md @@ -14,6 +14,7 @@ description: > (pgsql-reviewer). See "When to invoke" in the body. model: inherit color: cyan +permissionMode: readonly tools: - Read - Grep @@ -39,8 +40,8 @@ You are **pgsql-plan-analyst**, a report-only analyst of PostgreSQL `EXPLAIN` pl ## How to analyse -1. **Load the skills.** Invoke the Skill tool with `pgsql-coding:pgsql-performance` and read its `explain.md` reference; read `statistics.md` when an estimate is off and `rewrites.md` before proposing a rewrite. Load `pgsql-coding:pgsql-indexing` before proposing an index and `pgsql-coding:pgsql-jsonb` when a filter is on a jsonb key. Note the base directory the Skill tool prints for `pgsql-performance`. -2. **Run the summariser before reading any plan**: `python3 "/scripts/plan-summary.py" `; without a printed base directory, the script is `skills/pgsql-performance/scripts/plan-summary.py` under the plugin root (the folder that holds `skills/`). For a plan that arrived inline, pipe it through a quoted heredoc to `plan-summary.py -`. Without Python, read the plan by hand and say so. +1. **Load the skills.** On Claude Code, invoke the Skill tool with `pgsql-coding:pgsql-performance` and read its `explain.md` reference; read `statistics.md` when an estimate is off and `rewrites.md` before proposing a rewrite. Load `pgsql-coding:pgsql-indexing` before proposing an index and `pgsql-coding:pgsql-jsonb` when a filter is on a jsonb key. Note the base directory the Skill tool prints for `pgsql-performance`. On Cursor there is no Skill tool: read `/skills//SKILL.md` and its `references/` for the same skills, where the plugin root is the install path your brief or the session-start line gives. +2. **Run the summariser before reading any plan**: `python3 "/scripts/plan-summary.py" `. That directory is the base directory the Skill tool printed, or else `/skills/pgsql-performance`; never `skills/` in the user's repository. For a plan that arrived inline, pipe it through a quoted heredoc to `plan-summary.py -`. Without Python, or if the script cannot be found, read the plan by hand and say so. 3. **Read only the flagged regions** of the plan (`Read` with an offset), and the query text and the table definitions if the caller gave them or the repository has them (`Grep` the migrations for the table and its indexes), so an index you propose does not already exist. 4. **Explain each finding** with the rule from the skill that owns it: an estimate error at its lowest node (stale or missing statistics, a jsonb or expression predicate with no statistics, correlated columns: `pgsql-performance` statistics), a node run thousands of times (the join strategy, a missing index on the inner side, a per-row subquery), a filter that discards most rows (a missing or unusable index: `pgsql-indexing`), a spill, JIT overhead on a short query. 5. **For two plans**, say what changed and why. A faster plan can be faster for a different reason than the one the caller assumes; for example, an expression index that the plan never scans can still fix an estimate, because `ANALYZE` gathers statistics on index expressions. diff --git a/agents/pgsql-reviewer.md b/agents/pgsql-reviewer.md index 942a40a..08492aa 100644 --- a/agents/pgsql-reviewer.md +++ b/agents/pgsql-reviewer.md @@ -9,11 +9,12 @@ description: > migration change (the assistant's own too), a pre-PR gate, and a review of named files or dimensions. Report-only and works alone: severity-ranked findings; never edits code, writes to a database or dispatches agents. If a workflow already owns the review seat, have its reviewer - load pgsql-coding:pgsql-coding and the focused skills instead of dispatching this one beside it. - Not for Go or PHP style (go-reviewer, php-reviewer), a long EXPLAIN plan (pgsql-plan-analyst), - or what squawk already flags. See "When to invoke" in the body. + read the pgsql-coding skills instead of dispatching this one beside it. Not for Go or PHP style + (go-reviewer, php-reviewer), a long EXPLAIN plan (pgsql-plan-analyst), or what squawk already + flags. See "When to invoke" in the body. model: inherit color: blue +permissionMode: readonly tools: - Read - Grep @@ -41,7 +42,7 @@ You are **pgsql-reviewer**, a report-only reviewer of SQL for PostgreSQL 18 (Pos ## How to review -1. **Load the skills.** Invoke the Skill tool with `pgsql-coding:pgsql-coding`, then the focused skill for each area the change touches, per its *Route, then load* table: `pgsql-query`, `pgsql-jsonb`, `pgsql-schema`, `pgsql-indexing`, `pgsql-performance`, `pgsql-migrations`. Read the references a finding depends on before you assert it. Without the Skill tool, say so in the closing note and work from the dimensions below. +1. **Load the skills.** On Claude Code, invoke the Skill tool with `pgsql-coding:pgsql-coding`, then the focused skill for each area the change touches, per its *Route, then load* table: `pgsql-query`, `pgsql-jsonb`, `pgsql-schema`, `pgsql-indexing`, `pgsql-performance`, `pgsql-migrations`. On Cursor there is no Skill tool: read `/skills//SKILL.md` for the same skills, where the plugin root is the install path your brief or the session-start line gives; never `skills/` in the user's repository. Read the references a finding depends on before you assert it. If the skills cannot be loaded or opened, say so in the closing note and work from the dimensions below. 2. **Get the change.** Review a diff you are handed, or run `git diff` when a staged or branch change is implied. For every table a query touches, find its definition (migrations, a schema dump, `CREATE TABLE` files) so you know its columns, constraints and indexes; most of these bugs live in the gap between a query and the schema. 3. **Walk every dimension below.** Each line is a cue; the skill in brackets owns the rule and its source, so read it before asserting a finding, and cite it. 4. *(Optional)* run `squawk ` on changed migrations, and `EXPLAIN` against a named database to confirm a plan claim. Don't block on missing tools. diff --git a/docs/authoring.md b/docs/authoring.md index d289b86..458e27e 100644 --- a/docs/authoring.md +++ b/docs/authoring.md @@ -11,8 +11,8 @@ This page is for contributors adding or changing a skill (including a slash-comm - **Router skill** (`pgsql-coding`): auto-invoked; names the tool to run and the focused skill to load. It holds no rules beyond the minimum checklist. Its one reference, `versions.md`, says which feature needs PostgreSQL 17, 18 or 19. - **Focused skill** (`pgsql-query`, `pgsql-jsonb`, `pgsql-schema`, `pgsql-indexing`, `pgsql-performance`, `pgsql-migrations`): load-on-use. `SKILL.md` carries the rules most tasks need and a table that routes to its references. This follows the progressive-disclosure pattern in Anthropic's [skill authoring best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices): the description is always on, the body loads on use, and a reference costs nothing until it is read. -- **Command skill** (`/pgsql-lint-setup`): a thin one-shot `SKILL.md` with `argument-hint` and `allowed-tools`, using `$ARGUMENTS` in the body. -- **Agent** (`pgsql-reviewer`): a context-isolated specialist. Use **`tools:`**, **never** `allowed-tools:`, which an agent file ignores, so the agent silently inherits *all* tools. Include `Skill` in `tools:` so the agent can load the skills, because a subagent does not inherit the parent's loaded skills. +- **Command skill** (`/pgsql-lint-setup`): a thin one-shot `SKILL.md`. On Claude Code it carries `argument-hint` and `allowed-tools` and reads `$ARGUMENTS`. Cursor ignores those fields; the directory comes from the user's message and defaults to the repository root. +- **Agent** (`pgsql-reviewer`, `pgsql-plan-analyst`): a context-isolated specialist. Use **`tools:`**, **never** `allowed-tools:`, which an agent file ignores, so on Claude Code the agent silently inherits *all* tools. Include `Skill` in `tools:` so a Claude Code subagent can load the skills, because it does not inherit the parent's loaded skills. Cursor gives a subagent every tool and has no Skill tool ([subagents](https://cursor.com/docs/context/subagents)): the body tells it to read `/skills//SKILL.md`, a path the brief or the session-start line supplies. Report-only agents also set `permissionMode: readonly`. Cursor documents `readonly: true` for subagents and does not mention `permissionMode`; plugin agents are reported to ignore `readonly: true`, so this plugin sets `permissionMode`, and the Cursor check in [testing.md](testing.md#local-triggering-tests) confirms it. Claude Code ignores `permissionMode` in a plugin agent ([sub-agents](https://code.claude.com/docs/en/sub-agents)). No host stops `Bash` or `psql` from writing, so the bodies state no-edit and no-write as contracts. - **Cursor rule** (`rules/pgsql-context.mdc`): a Cursor-only `.mdc` with `description`, `globs` and `alwaysApply` that mirrors the router. ## The `description` (the trigger) @@ -108,7 +108,7 @@ Each reference's `## Sources` lists the manual chapters it rests on; those pages ## Dual-host parity -Skills (slash-command skills included) and agents are shared by both hosts. The **Cursor** manifest (`.cursor-plugin/plugin.json`) declares each component path; **Claude** discovers the default folders. Keep the two manifests' shared metadata identical (`scripts/validate.py` checks parity), and the Cursor hook commands **workspace-relative** (`bash hooks/session-start.sh`), never `${CLAUDE_PLUGIN_ROOT}`. +Skills (slash-command skills included) and agents are shared by both hosts. The **Cursor** manifest (`.cursor-plugin/plugin.json`) declares each component path; **Claude** discovers the default folders. Keep the two manifests' shared metadata identical (`scripts/validate.py` checks parity). Cursor hook commands are **relative to the plugin install** (`bash hooks/session-start.sh`), never `${CLAUDE_PLUGIN_ROOT}`, because a plugin hook runs in that install directory. A shared skill that tells the model to open another skill names both hosts: Claude Code's Skill tool takes `pgsql-coding:`; on Cursor the model reads that skill's `SKILL.md` in the plugin install, relative to the skill it is reading or at the path the session-start line prints. A Cursor subagent cannot find the install on its own, so its brief carries the absolute path. ## Before committing diff --git a/docs/install.md b/docs/install.md index 4405b87..b6d1e1b 100644 --- a/docs/install.md +++ b/docs/install.md @@ -67,8 +67,8 @@ The plugin ships no MCP server. When one is connected (for example Postgres MCP The plugin ships three host-agnostic hooks (Claude `hooks/hooks.json`, Cursor `hooks/cursor-hooks.json`). All three always exit 0 and never block an edit. -- **`session-start.sh`**: on session start, detects PostgreSQL use (pgx or lib/pq in `go.mod`, `ext-pdo_pgsql` or `ext-pgsql` in `composer.json`, a postgres, PostGIS or TimescaleDB image in a compose file, or a `*.sql` file within four directory levels) and prints one standards line. It suggests `/pgsql-lint-setup` when the repository has no `.squawk.toml`. Under Cursor, which runs a plugin hook from the plugin's install directory, it inspects `CURSOR_PROJECT_DIR` and prints the line as JSON `additional_context`. -- **`lint-on-save.sh`**: after each edit of a `*.sql` file (Claude `PostToolUse` on `Write`/`Edit`; Cursor `postToolUse` on `Write`), runs `squawk --reporter gcc` on that file, from the directory of the nearest `.squawk.toml` above it, so the project's settings apply. It runs only when squawk is on `PATH` **and** such a config exists: squawk's rules target migrations, so the project opts in. Up to 20 findings reach the model as `hookSpecificOutput.additionalContext` under Claude Code and as `additional_context` under Cursor. -- **`skill-nudge.sh`**: after each edit of a `.sql`, `.go` or `.php` file, names the one focused skill the added text calls for (a migration path or `ALTER TABLE` → `pgsql-migrations`; jsonb operators or SQL/JSON functions → `pgsql-jsonb`; `CREATE INDEX` → `pgsql-indexing`; `EXPLAIN` or `pg_stat_statements` → `pgsql-performance`; `CREATE TABLE`, functions, triggers, policies → `pgsql-schema`; other statements, pgx calls or PDO calls → `pgsql-query`), once per skill per session. The added text is Claude Code's `new_string` or `content`; Cursor documents no input fields for its `Write` tool, so there the hook matches the whole file. The session is Claude Code's `session_id` or Cursor's `conversation_id`. Go and PHP edits nudge only on unmistakable SQL, so ordinary host-language edits stay with go-coding and php-coding. +- **`session-start.sh`**: on session start, detects PostgreSQL use (pgx or lib/pq in `go.mod`, `ext-pdo_pgsql` or `ext-pgsql` in `composer.json`, a postgres, PostGIS or TimescaleDB image in a compose file, or a `*.sql` file within four directory levels) and prints one standards line. It suggests `/pgsql-lint-setup` when the repository has no `.squawk.toml`. Under Cursor, which runs a plugin hook from the plugin's install directory, it inspects `CURSOR_PROJECT_DIR` and prints the line, with the skills' path in the plugin install, as JSON `additional_context`. +- **`lint-on-save.sh`**: after each edit of a `*.sql` file (Claude `PostToolUse` on `Write`/`Edit`; Cursor `postToolUse` on `Write` or `StrReplace`), runs `squawk --reporter gcc` on that file, from the directory of the nearest `.squawk.toml` above it, so the project's settings apply. It runs only when squawk is on `PATH` **and** such a config exists: squawk's rules target migrations, so the project opts in. Up to 20 findings reach the model as `hookSpecificOutput.additionalContext` under Claude Code and as `additional_context` under Cursor. Cursor is told the path of the `pgsql-migrations` skill in the plugin install; Claude Code is told to load `pgsql-coding:pgsql-migrations`. +- **`skill-nudge.sh`**: after each edit of a `.sql`, `.go` or `.php` file, names the one focused skill the added text calls for (a migration path or `ALTER TABLE` → `pgsql-migrations`; jsonb operators or SQL/JSON functions → `pgsql-jsonb`; `CREATE INDEX` → `pgsql-indexing`; `EXPLAIN` or `pg_stat_statements` → `pgsql-performance`; `CREATE TABLE`, functions, triggers, policies → `pgsql-schema`; other statements, pgx calls or PDO calls → `pgsql-query`), once per skill per session. The added text is Claude Code's `new_string` or `content`, or Cursor's `StrReplace` `new_string`. A Cursor `Write` payload with neither matches the whole file. Line breaks in the added text count as spaces, so a keyword on a later line still matches. The session is Claude Code's `session_id` or Cursor's `conversation_id`. Go and PHP edits nudge only on unmistakable SQL, so ordinary host-language edits stay with go-coding and php-coding. Cursor is told the skill's `SKILL.md` path in the plugin install; Claude Code is told to load `pgsql-coding:`. -> The Cursor wiring follows [Cursor hooks](https://cursor.com/docs/agent/hooks): `postToolUse` with matcher `Write`, whose `additional_context` is "injected into the conversation after the tool result", rather than `afterFileEdit`, for which Cursor documents no output. Cursor does not document the `Write` tool's input fields, so the scripts read `file_path` or `path`; the Cursor checks in [testing.md](testing.md#local-triggering-tests) confirm it on a real install. If your Cursor version names the edit tool or its path field differently, adjust the matcher in `hooks/cursor-hooks.json` and the extraction in the scripts. +> The Cursor wiring follows [Cursor hooks](https://cursor.com/docs/agent/hooks): `postToolUse` with matcher `Write|StrReplace`, whose `additional_context` is "injected into the conversation after the tool result", rather than `afterFileEdit`, which documents no output fields (its matcher is the constant `Write`, so it cannot select `StrReplace` either). The scripts read `file_path` or `path`. A relative path is joined to `CURSOR_PROJECT_DIR`, because the hook runs in the plugin install. The Cursor checks in [testing.md](testing.md#local-triggering-tests) confirm it on a real install, including a `StrReplace` edit. If your Cursor version names the edit tool differently, adjust the matcher in `hooks/cursor-hooks.json`. diff --git a/docs/testing.md b/docs/testing.md index 5adf751..a0974f1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -16,7 +16,7 @@ claude plugin validate . # manifest and component structure ( `scripts/validate.py` checks: - **Manifests**: both parse, carry `name`, `version`, `description` and an `author` object, agree on `name`, `version`, `description`, `author`, `license`, `repository` and `keywords`, and every declared path exists. -- **Hooks**: both configs exist and parse; every `hooks/*.sh` is wired for the equivalent event on both hosts (`SessionStart`/`sessionStart`, `PostToolUse`/`postToolUse`), exists, and is executable; Claude Code commands reach their script through `${CLAUDE_PLUGIN_ROOT}`, Cursor commands never use it, and the Cursor config carries `"version": 1`. +- **Hooks**: both configs exist and parse; every `hooks/*.sh` is wired for the equivalent event on both hosts (`SessionStart`/`sessionStart`, `PostToolUse`/`postToolUse`), exists, and is executable; Claude Code commands reach their script through `${CLAUDE_PLUGIN_ROOT}`, Cursor commands never use it, the Cursor config carries `"version": 1`, and each edit hook's matcher names the host's edit tools (`Write|Edit`, `Write|StrReplace`). - **Frontmatter**: `name` equals the directory or filename, descriptions fit 1,024 characters (a folded `>-` or literal `|` block measured whole), no unquoted `: ` or ` #`, and agents declare `tools:`, never `allowed-tools:`. - **Progressive disclosure**: every `skills/*/references/*.md` is linked from its `SKILL.md` and every link resolves; references carry no frontmatter, open with `## Contents` past 100 lines, stay under 200 lines, and end with `## Sources`. A `SKILL.md` body stays under 1,500 words. - **Routing surface**: the router, `rules/pgsql-context.mdc`, the session-start banner and `pgsql-reviewer` each name every focused skill. @@ -31,9 +31,9 @@ CI (`.github/workflows/validate.yml`) runs the validator, `--selftest`, the hook `scripts/hooks-test.sh` covers all three hooks with real host payload shapes: -- `session-start`: detection by `go.mod` (pgx or lib/pq), `composer.json` (`ext-pdo_pgsql`), a compose file with a postgres image, or a `*.sql` file; silence elsewhere; the `/pgsql-lint-setup` hint only without a `.squawk.toml`; exit 0; under Cursor, the workspace in `CURSOR_PROJECT_DIR` inspected rather than the plugin directory, and valid JSON `additional_context`. -- `skill-nudge`: one skill per edit, chosen from the text the edit adds (not the deleted text or the tool response echo) in Claude Code's payload; the whole file under Cursor's `postToolUse` payload, whose `Write` input is undocumented; once per skill per session (`session_id` or `conversation_id`); `hookSpecificOutput.additionalContext` under Claude Code, `additional_context` under Cursor, both valid JSON. -- `lint-on-save`: a stub `squawk` on PATH proves the findings reach the model as valid JSON on both hosts with colour codes stripped, that nothing runs without a `.squawk.toml` or without squawk, and that the hook always exits 0. +- `session-start`: detection by `go.mod` (pgx or lib/pq), `composer.json` (`ext-pdo_pgsql`), a compose file with a postgres image, or a `*.sql` file; silence elsewhere; the `/pgsql-lint-setup` hint only without a `.squawk.toml`; exit 0; the Claude Code line naming skills only; under Cursor, the workspace in `CURSOR_PROJECT_DIR` inspected rather than the plugin directory, every skill listed with the install path, and valid JSON `additional_context`, even with a quote in that path. +- `skill-nudge`: one skill per edit, chosen from the text the edit adds (not the deleted text or the tool response echo) in Claude Code's payload and in Cursor's `StrReplace` `new_string`, including a keyword on a later line of a multi-line edit; the whole file when a Cursor payload has no added-text field; a relative Cursor path joined to `CURSOR_PROJECT_DIR` and an absolute one left as is; once per skill per session (`session_id` or `conversation_id`); `hookSpecificOutput.additionalContext` under Claude Code (`pgsql-coding:`), `additional_context` under Cursor (the skill's install path, JSON-escaped), both valid JSON. +- `lint-on-save`: a stub `squawk` on PATH proves the findings reach the model as valid JSON on both hosts with colour codes stripped, Claude Code told `pgsql-coding:pgsql-migrations` and Cursor the skill's install path, a relative Cursor path linted under `CURSOR_PROJECT_DIR`, that nothing runs without a `.squawk.toml` or without squawk, and that the hook always exits 0. ## Plan summariser tests @@ -71,6 +71,7 @@ Load a working copy (see [install.md](install.md)), then, in a repository that u - **`/pgsql-lint-setup`**: writes `.squawk.toml` and does not overwrite an existing one unprompted. - **`skill-nudge` and `lint-on-save` hooks**: edit a `.sql` file that adds a jsonb predicate and confirm the `pgsql-jsonb` nudge; edit a migration with `CREATE INDEX` (no `CONCURRENTLY`) in a repository with a `.squawk.toml` and confirm the squawk finding arrives. - **Cursor rule**: open a `.sql` file and confirm `pgsql-context.mdc` attaches. -- **Cursor hooks**: in a PostgreSQL workspace other than this repository, confirm the session-start line reaches the agent and an edit to a `.sql` file triggers the nudge. Cursor does not document the `Write` tool's input fields, so this is the only check that the hooks find the edited file there. +- **Cursor hooks**: in a PostgreSQL workspace other than this repository, confirm the session-start line reaches the agent (it names the skills' install path) and that both a new `.sql` file (`Write`) and an edit of an existing one (`StrReplace`) trigger the nudge. `afterFileEdit` is not the event: it documents no output, so the nudge would not reach the model. +- **Cursor agents**: ask `pgsql-reviewer` to fix the file it reviewed and confirm it refuses, which shows `permissionMode: readonly` is honoured; then confirm it read a skill from the path the brief gave. After editing content, reinstall or restart the session. diff --git a/hooks/cursor-hooks.json b/hooks/cursor-hooks.json index d2c438a..4916151 100644 --- a/hooks/cursor-hooks.json +++ b/hooks/cursor-hooks.json @@ -10,12 +10,12 @@ "postToolUse": [ { "command": "bash hooks/lint-on-save.sh", - "matcher": "Write", + "matcher": "Write|StrReplace", "timeout": 20 }, { "command": "bash hooks/skill-nudge.sh", - "matcher": "Write", + "matcher": "Write|StrReplace", "timeout": 5 } ] diff --git a/hooks/lint-on-save.sh b/hooks/lint-on-save.sh index 88eb18b..6ebc595 100755 --- a/hooks/lint-on-save.sh +++ b/hooks/lint-on-save.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# PostToolUse (Claude Code) / postToolUse on Write (Cursor) hook: lint the just-edited .sql file -# with squawk. +# PostToolUse (Claude Code) / postToolUse on Write or StrReplace (Cursor) hook: lint the +# just-edited .sql file with squawk. # # Opt-in by the project: runs only when a .squawk.toml exists in the file's directory or a parent # (the file /pgsql-lint-setup writes) and squawk is on PATH. Squawk's rules target migration @@ -10,8 +10,9 @@ # # File-path resolution, in order: # 1. $CLAUDE_FILE_PATH — explicit override for tests and manual runs; no host sets it. -# 2. tool payload JSON on stdin — Claude (`tool_input.file_path`) or Cursor (`file_path` or -# `path`; Cursor documents no Write input fields). Extracted without a jq/python dependency. +# 2. tool payload JSON on stdin — Claude (`tool_input.file_path`) or Cursor (`path` from +# StrReplace, or `file_path`; Cursor documents no Write input fields). Extracted without a +# jq/python dependency. Under Cursor a relative path is joined to CURSOR_PROJECT_DIR. set -u # Cheapest exit first: without squawk there is nothing to do, so do not even read the payload. @@ -27,6 +28,18 @@ if [ ! -t 0 ]; then fi [ -n "$f" ] || exit 0 +# Cursor plugin hooks run in the plugin install. A relative tool path is relative to the +# workspace, which CURSOR_PROJECT_DIR names. Absolute paths are left as they are. +cursor=0 +if [ -n "${CURSOR_PLUGIN_ROOT:-}" ] || printf '%s' "$payload" | grep -q '"cursor_version"'; then + cursor=1 +fi +if [ "$cursor" -eq 1 ] && [ -n "${CURSOR_PROJECT_DIR:-}" ]; then + case "$f" in + /*) ;; + *) f="${CURSOR_PROJECT_DIR%/}/$f" ;; + esac +fi case "$f" in *.sql) ;; *) exit 0 ;; esac [ -f "$f" ] || exit 0 @@ -48,7 +61,15 @@ esc="$(printf '\033')" out="$(printf '%s\n' "$out" | sed -e "s/${esc}\[[0-9;]*m//g" | grep -E ':[0-9]+:[0-9]+:' | head -n 20)" [ -n "$out" ] || exit 0 -msg="› pgsql-coding: squawk reports on $(basename "$f"); load pgsql-coding:pgsql-migrations and fix or justify each line:" +if [ "$cursor" -eq 1 ]; then + # Cursor has no Skill tool; name the skill's file in the plugin install, which holds hooks/. + # The whole message is JSON-escaped below, so the path needs no escaping here. + root="${CURSOR_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" + how="read the pgsql-migrations skill, ${root%/}/skills/pgsql-migrations/SKILL.md," +else + how="load pgsql-coding:pgsql-migrations" +fi +msg="› pgsql-coding: squawk reports on $(basename "$f"); ${how} and fix or justify each line:" # Build the JSON string by hand (no jq). Escape backslashes and quotes, turn tabs and newlines # into escapes, and drop any other control character squawk might print. body="$(printf '%s\n%s' "$msg" "$out" \ @@ -56,7 +77,7 @@ body="$(printf '%s\n%s' "$msg" "$out" \ | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' \ | awk 'NR > 1 { printf "\\n" } { printf "%s", $0 }')" # Cursor first: it sends hook_event_name too, and every Cursor payload carries cursor_version. -if [ -n "${CURSOR_PLUGIN_ROOT:-}" ] || printf '%s' "$payload" | grep -q '"cursor_version"'; then +if [ "$cursor" -eq 1 ]; then printf '{"additional_context":"%s"}\n' "$body" # Cursor postToolUse elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || printf '%s' "$payload" | grep -qE '"hook_event_name"[[:space:]]*:[[:space:]]*"PostToolUse"'; then printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}\n' "$body" diff --git a/hooks/session-start.sh b/hooks/session-start.sh index aa2b549..475ae27 100755 --- a/hooks/session-start.sh +++ b/hooks/session-start.sh @@ -33,8 +33,17 @@ uses_postgres() { if uses_postgres; then lint=" · /pgsql-lint-setup adds squawk (no .squawk.toml found)" [ -e .squawk.toml ] && lint="" - # A fixed literal with no quotes or backslashes, so the JSON form needs no escaping. - line="› PostgreSQL workspace: pgsql-coding (PostgreSQL 18 floor, 19 as hints). Load pgsql-coding then the skill for the change (pgsql-query · pgsql-jsonb · pgsql-schema · pgsql-indexing · pgsql-performance · pgsql-migrations); pgsql-reviewer for a review, pgsql-plan-analyst for a long EXPLAIN plan${lint}. EXPLAIN (ANALYZE, BUFFERS) before proposing an index or rewrite; squawk on migrations." + # One skill list for both hosts, so the validator's routing check covers both lines. Fixed + # literals with no quotes or backslashes; only the Cursor plugin path needs JSON escaping. + skills="pgsql-query · pgsql-jsonb · pgsql-schema · pgsql-indexing · pgsql-performance · pgsql-migrations" + if [ -n "$cursor" ]; then + # Cursor has no Skill tool: give the model, and the briefs it writes for subagents, the + # absolute path of each skill in the plugin install. + root="$(printf '%s' "${CURSOR_PLUGIN_ROOT%/}" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')" + line="› PostgreSQL workspace: pgsql-coding (PostgreSQL 18 floor, 19 as hints). Read the pgsql-coding skill, then the skill for the change (${skills}); each is ${root}/skills//SKILL.md, and a subagent brief passes that path. pgsql-reviewer for a review, pgsql-plan-analyst for a long EXPLAIN plan${lint}. EXPLAIN (ANALYZE, BUFFERS) before proposing an index or rewrite; squawk on migrations." + else + line="› PostgreSQL workspace: pgsql-coding (PostgreSQL 18 floor, 19 as hints). Load pgsql-coding then the skill for the change (${skills}); pgsql-reviewer for a review, pgsql-plan-analyst for a long EXPLAIN plan${lint}. EXPLAIN (ANALYZE, BUFFERS) before proposing an index or rewrite; squawk on migrations." + fi if [ -n "$cursor" ]; then printf '{"additional_context":"%s"}\n' "$line" else diff --git a/hooks/skill-nudge.sh b/hooks/skill-nudge.sh index 901b943..ba15395 100755 --- a/hooks/skill-nudge.sh +++ b/hooks/skill-nudge.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# PostToolUse (Claude Code) / postToolUse on Write (Cursor) hook: after a .sql, .go or .php file -# is edited, name ONE focused pgsql-coding skill the edit calls for, as context the model reads: -# hookSpecificOutput.additionalContext (Claude Code) or additional_context (Cursor). Once per -# skill per session. Always exits 0; never blocks an edit. +# PostToolUse (Claude Code) / postToolUse on Write or StrReplace (Cursor) hook: after a .sql, .go +# or .php file is edited, name ONE focused pgsql-coding skill the edit calls for, as context the +# model reads: hookSpecificOutput.additionalContext (Claude Code) or additional_context (Cursor). +# Once per skill per session. Always exits 0; never blocks an edit. # # Go and PHP files only nudge on unmistakable SQL (a jsonb operator, DDL, a pgx or PDO call), so # ordinary host-language edits stay silent; go-coding and php-coding own those. @@ -10,12 +10,25 @@ set -u f="${CLAUDE_FILE_PATH:-}"; sid=""; payload="" if [ ! -t 0 ]; then payload="$(cat)" - # Claude's tool_input.file_path; Cursor documents no Write input fields, so accept path too. + # Claude's tool_input.file_path; Cursor's StrReplace sends path and its Write fields are + # undocumented, so accept both. [ -n "$f" ] || f="$(printf '%s' "$payload" | grep -oE '"(file_?[Pp]ath|path)"[[:space:]]*:[[:space:]]*"[^"]+"' | head -n1 | sed -E 's/.*"([^"]+)"$/\1/')" # Claude Code names the session session_id; Cursor names it conversation_id. sid="$(printf '%s' "$payload" | grep -oE '"(session|conversation)_id"[[:space:]]*:[[:space:]]*"[^"]+"' | head -n1 | sed -E 's/.*"([^"]+)"$/\1/')" fi [ -n "$f" ] || exit 0 +# Cursor plugin hooks run in the plugin install. A relative tool path is relative to the +# workspace, which CURSOR_PROJECT_DIR names. Absolute paths are left as they are. +cursor=0 +if [ -n "${CURSOR_PLUGIN_ROOT:-}" ] || printf '%s' "$payload" | grep -q '"cursor_version"'; then + cursor=1 +fi +if [ "$cursor" -eq 1 ] && [ -n "${CURSOR_PROJECT_DIR:-}" ]; then + case "$f" in + /*) ;; + *) f="${CURSOR_PROJECT_DIR%/}/$f" ;; + esac +fi case "$f" in *.sql) kind=sql;; *.go) kind=go;; *.php) kind=php;; *) exit 0;; esac [ -f "$f" ] || exit 0 [ -n "$sid" ] || sid="ppid$PPID" @@ -25,12 +38,16 @@ case "$f" in *.sql) kind=sql;; *.go) kind=go;; *.php) kind=php;; *) exit 0;; esa json_field() { printf '%s' "$payload" | grep -oE "\"$1\"[[:space:]]*:[[:space:]]*\"(\\\\.|[^\"\\\\])*\""; } # Match the text the edit ADDS, not old_string or the tool_response echo: Claude Code's Edit -# new_string or Write content. A payload with neither (Cursor, whose Write input is undocumented, -# or a manual run with a path only) matches the whole file. +# new_string or Write content, and Cursor's StrReplace new_string. A payload with neither +# (Cursor Write, whose input fields are undocumented, or a manual run with a path only) +# matches the whole file. case "$payload" in *'"new_string"'*|*'"content"'*) subject="$(json_field new_string; json_field content)"; what="this edit";; *) subject="$(cat "$f")"; what="this file";; esac +# The added text is still JSON-escaped: a line break is \n, a tab \t. Turn those into spaces so +# a keyword at the start of a later line keeps its word edge. +subject="$(printf '%s' "$subject" | sed -e 's/\\[nrt]/ /g')" # -e: some patterns start with "-", which grep would otherwise read as an option. No \b: BSD # grep lacks it, so word edges are spelled out. has() { printf '%s' "$subject" | grep -qiE -e "$1"; } @@ -65,20 +82,29 @@ fi [ -n "$skill" ] || exit 0 marker="${TMPDIR:-/tmp}/pgsql-coding-nudge.${sid}.${skill}" [ -e "$marker" ] && exit 0 -: > "$marker" 2>/dev/null || true -# what/topic/skill are fixed literals set in this script (no quotes or backslashes), so the -# message needs no JSON escaping. Escape first if any of them is ever built from external text. -msg="› pgsql-coding: ${what} touches ${topic}; load pgsql-coding:${skill} before continuing." +# what/topic/skill are fixed literals set in this script (no quotes or backslashes). The Cursor +# plugin path comes from outside, so it is JSON-escaped before it joins the message. +if [ "$cursor" -eq 1 ]; then + # Cursor has no Skill tool; name the skill's file in the plugin install, which holds hooks/. + root="${CURSOR_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" + root="$(printf '%s' "${root%/}" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')" + how="read the ${skill} skill, ${root}/skills/${skill}/SKILL.md," +else + how="load pgsql-coding:${skill}" +fi +msg="› pgsql-coding: ${what} touches ${topic}; ${how} before continuing." # Cursor first: it sends hook_event_name too, and every Cursor payload carries cursor_version. -if [ -n "${CURSOR_PLUGIN_ROOT:-}" ] || printf '%s' "$payload" | grep -q '"cursor_version"'; then +if [ "$cursor" -eq 1 ]; then # Cursor postToolUse: additional_context is injected after the tool result # (https://cursor.com/docs/agent/hooks). - printf '{"additional_context":"%s"}\n' "$msg" + out="$(printf '{"additional_context":"%s"}' "$msg")" elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || printf '%s' "$payload" | grep -qE '"hook_event_name"[[:space:]]*:[[:space:]]*"PostToolUse"'; then # Claude Code: additionalContext reaches the model; a top-level systemMessage reaches only the # user (https://code.claude.com/docs/en/hooks). - printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}\n' "$msg" + out="$(printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}' "$msg")" else - printf '%s\n' "$msg" # a manual run: plain line + out="$msg" # a manual run: plain line fi +# Mark the skill as nudged only once the message is out, so a failed write does not use it up. +printf '%s\n' "$out" && { : > "$marker"; } 2>/dev/null exit 0 diff --git a/rules/pgsql-context.mdc b/rules/pgsql-context.mdc index f282924..6a226f2 100644 --- a/rules/pgsql-context.mdc +++ b/rules/pgsql-context.mdc @@ -6,7 +6,7 @@ alwaysApply: false # PostgreSQL coding standards -This Cursor rule mirrors the `pgsql-coding` router skill. Apply it when editing SQL, and when a Go or PHP change carries SQL text. Two principles: +This Cursor rule mirrors the `pgsql-coding` router skill and attaches to `.sql` files only; for SQL inside a `.go` or `.php` file, read the `pgsql-query` skill or the sibling the change needs. Two principles: - **Evidence beats reasoning.** A plan from `EXPLAIN (ANALYZE, BUFFERS)` decides whether an index or a rewrite helps. `squawk` decides whether a migration takes a dangerous lock. `plpgsql_check` finds PL/pgSQL errors. Run the tool; do not reason out what it decides. - **The floor is PostgreSQL 18.** PostgreSQL 19 additions are hints until the project's server runs 19. The router's `versions.md` reference lists what needs 17, 18 or 19. @@ -23,7 +23,7 @@ This Cursor rule mirrors the `pgsql-coding` router skill. Apply it when editing ## Route, then load -This rule is an index, not the standard. Before writing or reviewing SQL, open the focused skill matching the change. It carries the cited rules and the judgment: +This rule is an index, not the standard. Before writing or reviewing SQL, read the focused skill matching the change: `/skills//SKILL.md`, with the install path the session-start line prints (`/` opens it in chat), never `skills/` in the user's repository. It carries the cited rules and the judgment: - a query, a SQL string in `.go` or `.php` code, `JOIN`, `GROUP BY`, `ON CONFLICT`, `MERGE`, `COPY`, `FOR UPDATE`, `pg_advisory_*`, a transaction, `pgx.`, `pgxpool.`, `PDO` → `pgsql-query` - `jsonb`, `json`, `->`, `->>`, `@>`, `@?`, a `jsonb_*` or `JSON_*` function, `json.RawMessage` in a scan → `pgsql-jsonb` @@ -34,11 +34,11 @@ This rule is an index, not the standard. Before writing or reviewing SQL, open t ## Reading a plan -Summarise a plan before reading it: `python3 skills/pgsql-performance/scripts/plan-summary.py plan.txt`, from the plugin root, prints where the time goes and where a row-estimate error starts. For a plan over about 50 lines or two plans to compare, use the `pgsql-plan-analyst` agent with the plan in a file. +Summarise a plan before reading it: `python3 "/skills/pgsql-performance/scripts/plan-summary.py" plan.txt` prints where the time goes and where a row-estimate error starts. For a plan over about 50 lines or two plans to compare, use the `pgsql-plan-analyst` agent with the plan in a file. ## Reviewing SQL -For a focused review of `.sql` files, migrations or query strings in Go or PHP code, use the `pgsql-reviewer` agent. It loads the skills, returns findings ranked by severity, and edits nothing. +For a focused review of `.sql` files, migrations or query strings in Go or PHP code, use the `pgsql-reviewer` agent. It reads the skills, returns findings ranked by severity, and edits nothing. ## Running SQL against a database @@ -60,4 +60,4 @@ Anything a person reads goes in plain English, with the effect before the mechan When two forms return the same rows and both pass the tools, decide by: correct under NULLs and concurrent writes, then the measured plan on realistic data, then the form the PostgreSQL manual documents for that purpose, then the shorter statement. Say which of these decided it. -Ground every judgment in a cited source: the PostgreSQL 18 manual, the release notes, the PostgreSQL wiki, the pgx and PDO documentation, and the squawk rule catalogue. Do not invent rules. Adopt the shipped squawk config (`references/squawk.toml`, at the **plugin root**; search the installed plugin directory for it, not this rule's own folder). +Ground every judgment in a cited source: the PostgreSQL 18 manual, the release notes, the PostgreSQL wiki, the pgx and PDO documentation, and the squawk rule catalogue. Do not invent rules. Adopt the shipped squawk config (`references/squawk.toml` at the **plugin root**, beside `skills/`; `/pgsql-lint-setup` writes it into a repository). diff --git a/scripts/hooks-test.sh b/scripts/hooks-test.sh index d2f1d8c..c4c6c68 100755 --- a/scripts/hooks-test.sh +++ b/scripts/hooks-test.sh @@ -29,6 +29,8 @@ chk "banner names the plan analyst" "$(banner "$t/sql-only")" "pgsql-plan chk "banner states the version floor" "$(banner "$t/sql-only")" "PostgreSQL 18 floor" chk "no squawk config offers lint setup" "$(banner "$t/sql-only")" "/pgsql-lint-setup" chk_absent "squawk config omits lint setup" "$(banner "$t/with-squawk")" "/pgsql-lint-setup" +chk "Claude banner loads skills by name" "$(banner "$t/sql-only")" "Load pgsql-coding then the skill for the change (pgsql-query" +chk_absent "Claude banner names no file path" "$(banner "$t/sql-only")" "SKILL.md" chk_silent "go repo without pgx is silent" "$(banner "$t/go-plain")" chk_silent "unrelated repo is silent" "$(banner "$t/none")" for d in go-pgx none; do @@ -40,10 +42,19 @@ done cursor_banner() { (cd "$t/none" && CURSOR_PLUGIN_ROOT="$t/none" CURSOR_PROJECT_DIR="$1" bash "$here/hooks/session-start.sh"); } out="$(cursor_banner "$t/go-pgx")" chk "Cursor: the workspace, not the plugin directory, is inspected" "$out" '{"additional_context":"› PostgreSQL workspace' +for sk in pgsql-coding pgsql-query pgsql-jsonb pgsql-schema pgsql-indexing pgsql-performance pgsql-migrations; do + chk "Cursor banner names $sk" "$out" "$sk" +done +chk "Cursor banner gives the skills' install path" "$out" "$t/none/skills//SKILL.md" if command -v python3 >/dev/null 2>&1; then if printf '%s' "$out" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then ok "Cursor: session-start emits valid JSON"; else fail "Cursor: session-start emits valid JSON: '$out'"; fi fi chk_silent "Cursor: a workspace without PostgreSQL is silent" "$(cursor_banner "$t/go-plain")" +# The install path comes from outside the script, so it is JSON-escaped. +out="$(cd "$t/none" && CURSOR_PLUGIN_ROOT='/opt/a"b' CURSOR_PROJECT_DIR="$t/go-pgx" bash "$here/hooks/session-start.sh")" +if command -v python3 >/dev/null 2>&1; then + if printf '%s' "$out" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then ok "Cursor: a quote in the install path stays valid JSON"; else fail "Cursor: a quote in the install path stays valid JSON: '$out'"; fi +fi # --- skill-nudge ------------------------------------------------------------------------------- # p_edit/p_write mirror a real Claude Code PostToolUse payload (hook_event_name, tool_input with @@ -111,7 +122,7 @@ chk "hook_event_name delivers additionalContext" "$out" '{"hookSpecificOutput":{ clean "$s5" s6="pg-test-cursor-$$"; clean "$s6" out="$(printf '%s' "$(p_cursor "$s6" "$t/repo/j.sql")" | env -u CLAUDE_PLUGIN_ROOT bash "$here/hooks/skill-nudge.sh")" -chk "Cursor gets additional_context, matched on the file" "$out" '{"additional_context":"› pgsql-coding: this file touches JSON or jsonb; load pgsql-coding:pgsql-jsonb' +chk "Cursor gets additional_context, matched on the file" "$out" "{\"additional_context\":\"› pgsql-coding: this file touches JSON or jsonb; read the pgsql-jsonb skill, $here/skills/pgsql-jsonb/SKILL.md," if command -v python3 >/dev/null 2>&1; then if printf '%s' "$out" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then ok "Cursor: skill-nudge emits valid JSON"; else fail "Cursor: skill-nudge emits valid JSON: '$out'"; fi fi @@ -119,6 +130,44 @@ chk_silent "Cursor nudges once per conversation_id" \ "$(printf '%s' "$(p_cursor "$s6" "$t/repo/j.sql")" | env -u CLAUDE_PLUGIN_ROOT bash "$here/hooks/skill-nudge.sh")" clean "$s6" +# StrReplace carries new_string, so Cursor classifies the added text. A relative path is joined +# to CURSOR_PROJECT_DIR because the hook runs in the plugin install, not the workspace. +p_cursor_edit() { # CONVERSATION FILE NEW + printf '{"conversation_id":"%s","hook_event_name":"postToolUse","cursor_version":"1.7.2","workspace_roots":["/w"],"user_email":null,"transcript_path":null,"tool_name":"StrReplace","tool_input":{"path":"%s","old_string":"x","new_string":"%s"},"tool_output":"{}","duration":1}' \ + "$1" "$2" "$3" +} +printf 'SELECT 1;\n' > "$t/repo/plain.sql" +s7="pg-test-strreplace-$$"; clean "$s7" +out="$(printf '%s' "$(p_cursor_edit "$s7" "$t/repo/plain.sql" 'CREATE INDEX ON t (a);')" | env -u CLAUDE_PLUGIN_ROOT bash "$here/hooks/skill-nudge.sh")" +chk "Cursor StrReplace nudges from new_string" "$out" 'this edit touches an index; read the pgsql-indexing skill' +clean "$s7" +printf "SELECT doc->>'k' FROM t;\n" > "$t/repo/rel.sql" +s8="pg-test-rel-$$"; clean "$s8" +out="$(cd "$t/none" && printf '%s' "$(p_cursor_edit "$s8" "rel.sql" 'SELECT 1;')" | env -u CLAUDE_PLUGIN_ROOT -u CURSOR_PROJECT_DIR bash "$here/hooks/skill-nudge.sh")" +chk_silent "relative Cursor path without CURSOR_PROJECT_DIR is silent" "$out" +out="$(printf '%s' "$(p_cursor_edit "$s8" "rel.sql" 'SELECT 1;')" | env -u CLAUDE_PLUGIN_ROOT CURSOR_PROJECT_DIR="$t/repo" bash "$here/hooks/skill-nudge.sh")" +chk "relative Cursor path is joined to CURSOR_PROJECT_DIR" "$out" 'this edit touches a SQL statement; read the pgsql-query skill' +clean "$s8" +s9="pg-test-abs-$$"; clean "$s9" +out="$(printf '%s' "$(p_cursor_edit "$s9" "$t/repo/plain.sql" 'SELECT 1;')" | env -u CLAUDE_PLUGIN_ROOT CURSOR_PROJECT_DIR="$t/none" bash "$here/hooks/skill-nudge.sh")" +chk "absolute Cursor path ignores CURSOR_PROJECT_DIR" "$out" 'read the pgsql-query skill' +clean "$s9" +s10="pg-test-quote-$$"; clean "$s10" +out="$(printf '%s' "$(p_cursor_edit "$s10" "$t/repo/plain.sql" 'SELECT 1;')" | env -u CLAUDE_PLUGIN_ROOT CURSOR_PLUGIN_ROOT='/opt/a"b' bash "$here/hooks/skill-nudge.sh")" +if command -v python3 >/dev/null 2>&1; then + if printf '%s' "$out" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then ok "Cursor: a quote in the install path stays valid JSON (nudge)"; else fail "Cursor: a quote in the install path stays valid JSON (nudge): '$out'"; fi +fi +clean "$s10" + +# The added text arrives JSON-escaped, so a keyword after a line break follows "\n", not a word +# edge. Multi-line edits are the usual StrReplace and Edit shape. +s11="pg-test-multiline-$$"; clean "$s11"; clean "$s11-ddl" +chk "keyword on a later line still nudges" \ + "$(nudge p "$(p_edit "$s11" "$t/repo/q.sql" '' '-- fetch the user\nSELECT id FROM users WHERE id = $1;')")" "pgsql-coding:pgsql-query" +chk "keywords split across lines still nudge" \ + "$(nudge q "$(p_edit "$s11-ddl" "$t/repo/q.sql" '' 'ALTER\n TABLE t ADD COLUMN c int;')")" "pgsql-coding:pgsql-migrations" +clean "$s11"; clean "$s11-ddl" + # --- lint-on-save ------------------------------------------------------------------------------ # A stub squawk on PATH prints gcc-style findings (plus a quote, a backslash and a colour code # that the hook must escape or strip), so these cases do not depend on a real squawk install. @@ -138,15 +187,21 @@ out="$(lint "$(p_write lint "$t/lint/migrations/0002.sql" 'x')")"; st=$? [ "$st" -eq 0 ] && ok "lint-on-save exits 0 on findings" || fail "lint-on-save exits 0 on findings: exited $st" chk "findings reach the model as additionalContext" "$out" '"additionalContext":"› pgsql-coding: squawk reports on 0002.sql' chk "the finding names the squawk rule" "$out" "require-concurrent-index-creation" +chk "Claude Code lint loads the namespaced skill" "$out" "load pgsql-coding:pgsql-migrations" chk_absent "colour codes are stripped" "$out" "[31m" if command -v python3 >/dev/null 2>&1; then if printf '%s' "$out" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then ok "lint-on-save emits valid JSON"; else fail "lint-on-save emits valid JSON: '$out'"; fi fi out="$(printf '%s' "$(p_cursor cur "$t/lint/migrations/0002.sql")" | env -u CLAUDE_PLUGIN_ROOT PATH="$t/bin:$PATH" bash "$here/hooks/lint-on-save.sh")" chk "Cursor: findings reach the model as additional_context" "$out" '{"additional_context":"› pgsql-coding: squawk reports on 0002.sql' +chk "Cursor lint names the skill's install path" "$out" "read the pgsql-migrations skill, $here/skills/pgsql-migrations/SKILL.md," if command -v python3 >/dev/null 2>&1; then if printf '%s' "$out" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then ok "Cursor: lint-on-save emits valid JSON"; else fail "Cursor: lint-on-save emits valid JSON: '$out'"; fi fi +out="$(cd "$t/none" && printf '%s' "$(p_cursor_edit lintrel "migrations/0002.sql" 'x')" | env -u CLAUDE_PLUGIN_ROOT CURSOR_PROJECT_DIR="$t/lint" PATH="$t/bin:$PATH" bash "$here/hooks/lint-on-save.sh")" +chk "Cursor: a relative path is linted under CURSOR_PROJECT_DIR" "$out" 'squawk reports on 0002.sql; read the pgsql-migrations skill' +out="$(printf '%s' "$(p_cursor cur "$t/lint/migrations/0002.sql")" | env -u CLAUDE_PLUGIN_ROOT CURSOR_PROJECT_DIR="$t/nolint" PATH="$t/bin:$PATH" bash "$here/hooks/lint-on-save.sh")" +chk "Cursor: an absolute path ignores CURSOR_PROJECT_DIR" "$out" 'squawk reports on 0002.sql' chk_silent "no .squawk.toml means no lint" "$(lint "$(p_write lint "$t/nolint/0002.sql" 'x')")" chk_silent "non-SQL file is not linted" "$(lint "$(p_write lint "$t/repo/notes.md" 'x')")" out="$(CLAUDE_FILE_PATH="$t/lint/migrations/0002.sql" PATH=/nonexistent "$BASH" "$here/hooks/lint-on-save.sh" &1)"; st=$? diff --git a/scripts/validate.py b/scripts/validate.py index 658f7b2..b36dca4 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -11,7 +11,8 @@ * hook-config JSON validity, and dual-host hook parity: both configs present, the same ``hooks/*.sh`` wired for the equivalent event on both hosts, each wired script present and executable, none unwired; Claude commands through ``${CLAUDE_PLUGIN_ROOT}``, Cursor commands - workspace-relative and Cursor's config at ``"version": 1``; + never through it and Cursor's config at ``"version": 1``; every edit hook's matcher names + each host's edit tools (Claude ``Write|Edit``, Cursor ``Write|StrReplace``); * SKILL.md / agent / command frontmatter: required keys, ``name`` matching the directory or filename, descriptions within the Agent Skills limit (folded and literal blocks measured whole), no unquoted ``: `` or `` #``. Agents @@ -75,6 +76,8 @@ # Hook events that mean the same thing on each host, so the same scripts must be wired for # both. Claude event -> Cursor event. EQUIVALENT_HOOK_EVENTS = {"SessionStart": "sessionStart", "PostToolUse": "postToolUse"} +# The tools each host edits files with; an edit hook's matcher must name all of them. +EDIT_TOOLS = {"PostToolUse": {"Write", "Edit"}, "postToolUse": {"Write", "StrReplace"}} # Docs that inventory the component surface, and which kinds each one claims to cover. INVENTORY_DOCS = { "README.md": ("skills", "agents", "hooks"), @@ -351,7 +354,7 @@ def validate_hook_parity(): """Dual-host hook parity: the same scripts must be wired for the equivalent event on both hosts, every wired script must exist and be executable, and no `hooks/*.sh` may sit in the tree unwired. Claude Code commands reach their script through `${CLAUDE_PLUGIN_ROOT}`; Cursor - does not set it, so Cursor commands stay workspace-relative.""" + does not set it, so Cursor commands stay relative to the plugin install.""" claude_path, cursor_path = ROOT / "hooks" / "hooks.json", ROOT / "hooks" / "cursor-hooks.json" configs = [p for p in (claude_path, cursor_path) if p.is_file()] if not configs and not list((ROOT / "hooks").glob("*.sh")): @@ -374,7 +377,15 @@ def validate_hook_parity(): for cmd in commands: if "CLAUDE_PLUGIN_ROOT" in cmd: err(f"hooks/cursor-hooks.json: {event} command '{cmd}' uses CLAUDE_PLUGIN_ROOT, which Cursor " - f"does not set; keep the path workspace-relative") + f"does not set; keep the path relative to the plugin install") + for name, config in (("hooks.json", claude), ("cursor-hooks.json", cursor)): + for event, tools in EDIT_TOOLS.items(): + for entry in (config.get("hooks") or {}).get(event) or []: + matcher = str(entry.get("matcher") or "") if isinstance(entry, dict) else "" + missing = set() if matcher in ("", "*") else tools - set(matcher.split("|")) + if missing: + err(f"hooks/{name}: {event} matcher '{matcher}' misses {', '.join(sorted(missing))}, " + f"so an edit with that tool runs no hook") claude_events, cursor_events = _hook_scripts(claude), _hook_scripts(cursor) wired = set() for claude_event, cursor_event in EQUIVALENT_HOOK_EVENTS.items(): @@ -691,7 +702,7 @@ def _selftest_fetch_policy() -> int: SELFTEST_HOOKS_CLAUDE = {"hooks": {"PostToolUse": [{"matcher": "Write|Edit", "hooks": [ {"type": "command", "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/a.sh"}]}]}} -SELFTEST_HOOKS_CURSOR = {"version": 1, "hooks": {"postToolUse": [{"command": "bash hooks/a.sh", "matcher": "Write"}]}} +SELFTEST_HOOKS_CURSOR = {"version": 1, "hooks": {"postToolUse": [{"command": "bash hooks/a.sh", "matcher": "Write|StrReplace"}]}} def _selftest_tree(root: Path, *, break_it=None): @@ -735,7 +746,9 @@ def _selftest_tree(root: Path, *, break_it=None): if break_it == "hook_parity": cursor["hooks"]["postToolUse"] = [] if break_it == "cursor_plugin_root": - cursor["hooks"]["postToolUse"] = [{"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/a.sh", "matcher": "Write"}] + cursor["hooks"]["postToolUse"] = [{"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/a.sh", "matcher": "Write|StrReplace"}] + if break_it == "cursor_edit_matcher": + cursor["hooks"]["postToolUse"][0]["matcher"] = "Write" if break_it == "cursor_no_version": del cursor["version"] if break_it == "claude_relative": @@ -765,6 +778,7 @@ def _selftest_tree(root: Path, *, break_it=None): ("Cursor hook command uses CLAUDE_PLUGIN_ROOT", "cursor_plugin_root", (validate_hook_parity,)), ("Claude hook command is workspace-relative", "claude_relative", (validate_hook_parity,)), ("Cursor hook config without version 1", "cursor_no_version", (validate_hook_parity,)), + ("Cursor edit hook misses StrReplace", "cursor_edit_matcher", (validate_hook_parity,)), ("folded description over the limit", "description_folded", (validate_skills,)), ("shipped skill missing from the docs", "doc_inventory", (validate_doc_inventories,)), ("unquoted ': ' in a description", "frontmatter_colon", (validate_skills,)), diff --git a/skills/pgsql-coding/SKILL.md b/skills/pgsql-coding/SKILL.md index c10ab5b..d7f31ee 100644 --- a/skills/pgsql-coding/SKILL.md +++ b/skills/pgsql-coding/SKILL.md @@ -25,7 +25,10 @@ Route the PostgreSQL task to the skill and the tool that own it. This skill is a ## Route, then load -Before writing or reviewing SQL, load the focused skill that matches the change with the Skill tool (`pgsql-coding:pgsql-jsonb`, and so on). Do not work from memory of it. +Before writing or reviewing SQL, load the focused skill that matches the change. Do not work from memory of it. + +- **Claude Code.** Invoke the Skill tool with `pgsql-coding:pgsql-jsonb`, and the same `pgsql-coding:` prefix for every skill in the table. +- **Cursor.** There is no Skill tool, and `pgsql-coding:pgsql-jsonb` is not a path. Read `../pgsql-jsonb/SKILL.md` from this file's directory, the same pattern for every skill in the table; the session-start line prints the install path, and `/pgsql-jsonb` opens the skill in chat. | The change touches | Load | |---|---| @@ -38,7 +41,7 @@ Before writing or reviewing SQL, load the focused skill that matches the change One load per skill per session is enough. A change usually needs two skills: a new jsonb query needs `pgsql-jsonb` and `pgsql-query`; a new index in a migration needs `pgsql-indexing` and `pgsql-migrations`. SQL inside Go or PHP code also needs that language's plugin (`go-coding`, `php-coding`) for the host code. -Subagents do not inherit loaded skills. An orchestrator that dispatches implementers or reviewers puts this table in every brief, with the database the subagent may query, if any. +Subagents do not inherit loaded skills. An orchestrator that dispatches implementers or reviewers puts this table in every brief, with the database the subagent may query, if any. On Claude Code the brief names `pgsql-coding:pgsql-query`. On Cursor it gives the absolute path of each skill's `SKILL.md` in the plugin install, which a subagent cannot find on its own. ## Running SQL against a database @@ -73,7 +76,7 @@ Anything a person reads, such as a PR description, a review comment or a questio ## For a focused review or a long plan -Dispatch the `pgsql-reviewer` agent. It is report-only and context-isolated: it loads these skills, reads SQL in `.sql` files, migrations and Go or PHP query strings, and returns severity-ranked findings. If a workflow already owns the reviewer seat, that reviewer loads the focused skills itself instead; one review seat per change. +Dispatch the `pgsql-reviewer` agent. It is report-only and context-isolated: it reads these skills, reads SQL in `.sql` files, migrations and Go or PHP query strings, and returns severity-ranked findings. If a workflow already owns the reviewer seat, that reviewer reads the focused skills itself instead; one review seat per change. -Dispatch the `pgsql-plan-analyst` agent for an `EXPLAIN` plan over about 50 lines or two plans to compare, with the plan saved to a file. It runs `pgsql-performance`'s plan summariser in its own context and returns a ranked diagnosis, so the plan never fills this conversation. +Dispatch the `pgsql-plan-analyst` agent for an `EXPLAIN` plan over about 50 lines or two plans to compare, with the plan saved to a file. It runs `scripts/plan-summary.py` from the `pgsql-performance` skill directory and returns a ranked diagnosis, so the plan never fills this conversation. diff --git a/skills/pgsql-lint-setup/SKILL.md b/skills/pgsql-lint-setup/SKILL.md index 2e61ec7..88b3e0c 100644 --- a/skills/pgsql-lint-setup/SKILL.md +++ b/skills/pgsql-lint-setup/SKILL.md @@ -12,13 +12,13 @@ allowed-tools: # pgsql-lint-setup: the reference squawk config -> **The shipped config is at the plugin root**, `references/squawk.toml` (beside `skills/`, two levels above this file), not under this skill. Read it as `${CLAUDE_PLUGIN_ROOT}/references/squawk.toml` on Claude Code, as `../../references/squawk.toml` from this skill's directory, or Glob for the installed `references/squawk.toml`. The block below is the same file. +> **Write the block below.** It is the same text as `references/squawk.toml` at the plugin root (beside `skills/`). To compare with the shipped file, read `../../references/squawk.toml` from this skill's directory. On Claude Code that file is also `${CLAUDE_PLUGIN_ROOT}/references/squawk.toml`. Do not Glob the user's repository for it. Scope: write squawk's config into a repository and report how to run it, in one interaction. squawk lints migration files for statements that block reads or writes or break running clients ([squawk rules](https://squawkhq.com/docs/rules)). Deterministic backstop: `squawk` itself; this skill only configures it. -`$ARGUMENTS` is an optional directory. Default is the repository root. Call that directory `$ROOT` below. +`$ARGUMENTS` is an optional directory on Claude Code. On Cursor, `argument-hint`, `allowed-tools` and `$ARGUMENTS` are not applied: take an optional directory from the user's message. Default on both hosts is the repository root. Call that directory `$ROOT` below. ## Steps diff --git a/skills/pgsql-performance/SKILL.md b/skills/pgsql-performance/SKILL.md index 2ace5bc..ddfa877 100644 --- a/skills/pgsql-performance/SKILL.md +++ b/skills/pgsql-performance/SKILL.md @@ -27,7 +27,7 @@ Deterministic backstop: `EXPLAIN (ANALYZE, BUFFERS)` for one statement, `pg_stat ## Summarise a plan before reading it -- **Run the summariser first.** `python3 /scripts/plan-summary.py plan.txt` (or `-` for stdin) reads TEXT or JSON plans, psql's aligned output included, and prints the totals, the nodes with the most exclusive time, and mechanical findings: the node where a row-estimate error starts and where it grows, sorts and hashes that spilled, nodes run thousands of times, filters that discard most rows, heap fetches, unlaunched workers, and JIT share. It is arithmetic on the plan, not a diagnosis; read the flagged lines and apply the rules above. +- **Run the summariser first.** `python3 "/scripts/plan-summary.py" plan.txt` (or `-` for stdin). This skill's directory is the folder that holds this file in the plugin install: the base directory the Skill tool prints on Claude Code, `/skills/pgsql-performance` on Cursor; never `skills/` in the user's repository. It reads TEXT or JSON plans, psql's aligned output included, and prints the totals, the nodes with the most exclusive time, and mechanical findings: the node where a row-estimate error starts and where it grows, sorts and hashes that spilled, nodes run thousands of times, filters that discard most rows, heap fetches, unlaunched workers, and JIT share. It is arithmetic on the plan, not a diagnosis; read the flagged lines and apply the rules above. - **Hand a long plan to `pgsql-plan-analyst`.** For a plan over about 50 lines, two plans to compare, or an analysis that would fill the conversation, write the plan to a scratch file and dispatch the agent with its path, the query, and the database it may query, if any. It returns a ranked diagnosis with the command that confirms each fix. ## Read the reference