From f26b3d1281d5ad110ece3c574257075ef74ad6ea Mon Sep 17 00:00:00 2001 From: Sebastian Iancu Date: Tue, 27 Oct 2026 13:59:12 +0100 Subject: [PATCH 1/3] fix: load skills and see edits on Cursor Cursor has no Skill tool and applies incremental edits with StrReplace. The shared skills, agents and rule now name /pgsql-* and the plugin install, postToolUse matches Write and StrReplace, and the report-only agents set permissionMode: readonly. Co-authored-by: Cursor --- AGENTS.md | 8 ++++---- CHANGELOG.md | 7 +++++++ README.md | 8 ++++---- agents/pgsql-plan-analyst.md | 7 ++++--- agents/pgsql-reviewer.md | 9 +++++---- docs/authoring.md | 6 +++--- docs/install.md | 6 +++--- docs/testing.md | 4 ++-- hooks/cursor-hooks.json | 4 ++-- hooks/lint-on-save.sh | 23 ++++++++++++++++++++--- hooks/session-start.sh | 9 +++++++-- hooks/skill-nudge.sh | 28 +++++++++++++++++++++++----- rules/pgsql-context.mdc | 6 +++--- scripts/hooks-test.sh | 23 ++++++++++++++++++++++- scripts/validate.py | 6 +++--- skills/pgsql-coding/SKILL.md | 11 +++++++---- skills/pgsql-lint-setup/SKILL.md | 4 ++-- skills/pgsql-performance/SKILL.md | 2 +- 18 files changed, 122 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fcc671c..a31910d 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`. A relative tool path is joined to `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/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 `/`. 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 `pgsql-coding:`; Cursor: `/` and the skill file in the plugin install), never edit, never run DML, and dispatch no sub-agents. Both declare `tools:` (Claude enforces it; Cursor does not) and `permissionMode: readonly` (what Cursor plugin agents enforce). Never `allowed-tools:` | | 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/CHANGELOG.md b/CHANGELOG.md index 1787c9b..04b8137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,3 +20,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Evals: one case per focused skill under `evals/`. - Scripts: `scripts/validate.py` (with `--selftest` and `--check-links`), `scripts/validate.sh`, `scripts/hooks-test.sh`. - Docs: `docs/install.md`, `docs/testing.md`, `docs/versioning.md`, `docs/authoring.md`; CI workflows `validate.yml` and `links.yml`. + +### Changed +- Hooks: Cursor `postToolUse` matchers are `Write|StrReplace`; a relative path is joined to `CURSOR_PROJECT_DIR`; Cursor nudges name `/` and Claude Code keeps `pgsql-coding:`. +- Skills: the router, `pgsql-performance`'s plan summariser and `/pgsql-lint-setup` say how Cursor opens a skill and where that skill's files live in the plugin install. +- Agents: `permissionMode: readonly` on `pgsql-reviewer` and `pgsql-plan-analyst`; both read skills from the plugin install on Cursor and still use the Skill tool on Claude Code. +- Cursor rule: `pgsql-context.mdc` attaches to `**/*.sql` only, and the plan summariser path is the installed `pgsql-performance` skill. +- Docs: Cursor hook commands are described as relative to the plugin install. diff --git a/README.md b/README.md index bbe1751..4e21d9a 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. Cursor sets `permissionMode: readonly`. | +| 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. Cursor sets `permissionMode: readonly`. | | 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, open `/pgsql-coding` and then `/pgsql-query` (or the sibling the change needs) and read that skill's `SKILL.md` from the plugin install. 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, open `/pgsql-coding` and the focused `/pgsql-*` skills and read each `SKILL.md` from the plugin install. 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 b0f368d..d194932 100644 --- a/agents/pgsql-plan-analyst.md +++ b/agents/pgsql-plan-analyst.md @@ -15,6 +15,7 @@ description: > the agent body for worked scenarios. model: inherit color: cyan +permissionMode: readonly tools: - Read - Grep @@ -23,7 +24,7 @@ tools: - Skill --- -You are **pgsql-plan-analyst**, a report-only analyst of PostgreSQL `EXPLAIN` plans (PostgreSQL 18; 19 additions are hints). You work in your own context so a long plan never fills the caller's, and you return a short, ranked diagnosis. `Bash` is for the plan summariser and read-only `psql`; no-edit and no-write are contracts you keep, not a sandbox that enforces them. +You are **pgsql-plan-analyst**, a report-only analyst of PostgreSQL `EXPLAIN` plans (PostgreSQL 18; 19 additions are hints). You work in your own context so a long plan never fills the caller's, and you return a short, ranked diagnosis. `Bash` is for the plan summariser and read-only `psql`. Cursor enforces no edits through `permissionMode: readonly` (plugin agents ignore `readonly: true` and do not enforce `tools:`). Claude Code enforces the `tools:` list. Where a host enforces neither, no-edit and no-write are contracts you keep. ## When to invoke @@ -40,8 +41,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, read `skills/pgsql-performance/SKILL.md` from the plugin install (next to this agent file: `../skills/pgsql-performance/SKILL.md`) and the same `../skills//SKILL.md` pattern for `pgsql-indexing` and `pgsql-jsonb`. +2. **Run the summariser before reading any plan.** The script is `scripts/plan-summary.py` in the `pgsql-performance` skill directory. On Claude Code that is the directory the Skill tool printed. On Cursor it is `../skills/pgsql-performance/scripts/plan-summary.py` relative to this agent file. Do not run `skills/pgsql-performance/scripts/plan-summary.py` from 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 b2e3fe5..cdc376b 100644 --- a/agents/pgsql-reviewer.md +++ b/agents/pgsql-reviewer.md @@ -10,12 +10,13 @@ description: > assistant's own included), a pre-PR gate, and a review scoped to named files or dimensions. Report-only and works alone: returns severity-ranked findings, never edits code, never runs a statement that writes, and never dispatches agents. When a workflow already owns the review seat, - do not dispatch it beside that reviewer; have the reviewer load pgsql-coding:pgsql-coding and the - focused skills instead. Not for Go or PHP style (go-reviewer, php-reviewer), a long EXPLAIN + do not dispatch it beside that reviewer; have that reviewer read the pgsql-coding skills + instead (Claude Code: Skill tool `pgsql-coding:pgsql-coding`; Cursor: `/pgsql-coding`). 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 agent body for worked scenarios. model: inherit color: blue +permissionMode: readonly tools: - Read - Grep @@ -24,7 +25,7 @@ tools: - Skill --- -You are **pgsql-reviewer**, a report-only reviewer of SQL for PostgreSQL 18 (PostgreSQL 19 additions are hints, not requirements). You supply the judgment that survives the test suite and `squawk`. `Bash` is for `git diff`, `squawk` and read-only `psql`; no-edit and no-write are contracts you keep, not a sandbox that enforces them. +You are **pgsql-reviewer**, a report-only reviewer of SQL for PostgreSQL 18 (PostgreSQL 19 additions are hints, not requirements). You supply the judgment that survives the test suite and `squawk`. `Bash` is for `git diff`, `squawk` and read-only `psql`. Cursor enforces no edits through `permissionMode: readonly` (plugin agents ignore `readonly: true` and do not enforce `tools:`). Claude Code enforces the `tools:` list. Where a host enforces neither, no-edit and no-write are contracts you keep. ## When to invoke @@ -43,7 +44,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 `pgsql-coding:pgsql-query` and the same prefix for each focused skill the change touches. On Cursor there is no Skill tool: read `skills//SKILL.md` from the plugin install (the directory that holds this agent file, or the path Cursor gave you for `/pgsql-query`). The skills are `pgsql-query`, `pgsql-jsonb`, `pgsql-schema`, `pgsql-indexing`, `pgsql-performance`, `pgsql-migrations`, per the router's *Route, then load* table. Read the references a finding depends on before you assert it. If those files cannot be 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..ff7e818 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 does not enforce `tools:` and has no Skill tool: the body tells it to read `skills//SKILL.md` in the plugin install, or to open `/`. Report-only agents set `permissionMode: readonly`, which is the key Cursor plugin agents honor (`readonly: true` is ignored there). - **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:`, and Cursor takes `/` plus a read of that skill's `SKILL.md` in the plugin install. ## Before committing diff --git a/docs/install.md b/docs/install.md index 4405b87..0e5eff9 100644 --- a/docs/install.md +++ b/docs/install.md @@ -68,7 +68,7 @@ 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. +- **`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 to read `/pgsql-migrations`; 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. 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 to read `/`; 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..e8a0728 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -32,7 +32,7 @@ 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. +- `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`; the whole file when a Cursor payload has no added-text field; a relative Cursor path joined to `CURSOR_PROJECT_DIR`; once per skill per session (`session_id` or `conversation_id`); `hookSpecificOutput.additionalContext` under Claude Code (`pgsql-coding:`), `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. ## Plan summariser tests @@ -71,6 +71,6 @@ 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 `/pgsql-query`) 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. 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..820b85e 100755 --- a/hooks/lint-on-save.sh +++ b/hooks/lint-on-save.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# PostToolUse (Claude Code) / postToolUse on Write (Cursor) hook: lint the just-edited .sql file +# 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 @@ -27,6 +27,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 +60,12 @@ 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 + how="read /pgsql-migrations" +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 +73,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..7ece36a 100755 --- a/hooks/session-start.sh +++ b/hooks/session-start.sh @@ -33,8 +33,13 @@ 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." + # Fixed literals with no quotes or backslashes, so the JSON form needs no escaping. + skills="pgsql-query · pgsql-jsonb · pgsql-schema · pgsql-indexing · pgsql-performance · pgsql-migrations" + if [ -n "$cursor" ]; then + line="› PostgreSQL workspace: pgsql-coding (PostgreSQL 18 floor, 19 as hints). Open /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." + 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..23a6734 100755 --- a/hooks/skill-nudge.sh +++ b/hooks/skill-nudge.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# PostToolUse (Claude Code) / postToolUse on Write (Cursor) hook: after a .sql, .go or .php file +# 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. @@ -16,6 +16,18 @@ if [ ! -t 0 ]; then 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,8 +37,9 @@ 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";; @@ -68,9 +81,14 @@ marker="${TMPDIR:-/tmp}/pgsql-coding-nudge.${sid}.${skill}" : > "$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." +if [ "$cursor" -eq 1 ]; then + how="read /${skill}" +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" diff --git a/rules/pgsql-context.mdc b/rules/pgsql-context.mdc index f282924..9615372 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. It attaches to `.sql` files only. SQL inside a `.go` or `.php` file is not covered by this rule; open `/pgsql-query` or the sibling skill 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, and it is in context only for `.sql` files. Before writing or reviewing SQL, open the focused skill matching the change (`/pgsql-query`, and the same `/` prefix for each name below). On disk that file is `skills//SKILL.md` in the plugin install, not in the 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,7 +34,7 @@ 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. The script is `scripts/plan-summary.py` in the installed `pgsql-performance` skill (the directory of that skill's `SKILL.md`), not `skills/pgsql-performance/` in the repository. Run `python3 "/scripts/plan-summary.py" plan.txt`. 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 diff --git a/scripts/hooks-test.sh b/scripts/hooks-test.sh index d2f1d8c..05a7c02 100755 --- a/scripts/hooks-test.sh +++ b/scripts/hooks-test.sh @@ -40,6 +40,7 @@ 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' +chk "Cursor banner names a slash skill" "$out" "/pgsql-query" 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 @@ -111,7 +112,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 /pgsql-jsonb' 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 +120,25 @@ 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 /pgsql-indexing' +clean "$s7" +printf "SELECT doc->>'k' FROM t;\n" > "$t/repo/rel.sql" +s8="pg-test-rel-$$"; clean "$s8" +out="$(printf '%s' "$(p_cursor_edit "$s8" "rel.sql" 'SELECT 1;')" | env -u CLAUDE_PLUGIN_ROOT 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 /pgsql-query' +clean "$s8" + # --- 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. @@ -144,6 +164,7 @@ if command -v python3 >/dev/null 2>&1; then 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 slash skill" "$out" "read /pgsql-migrations" 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 diff --git a/scripts/validate.py b/scripts/validate.py index 658f7b2..a5bbbe8 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -11,7 +11,7 @@ * 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``; + relative to the plugin install and Cursor's config at ``"version": 1``; * 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 @@ -351,7 +351,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 +374,7 @@ 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") claude_events, cursor_events = _hook_scripts(claude), _hook_scripts(cursor) wired = set() for claude_event, cursor_event in EQUIVALENT_HOOK_EVENTS.items(): diff --git a/skills/pgsql-coding/SKILL.md b/skills/pgsql-coding/SKILL.md index c10ab5b..7010ef0 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.** Open `/pgsql-jsonb` (the same `/` prefix for every skill in the table). Its files are in the plugin install, next to this skill: read `../pgsql-jsonb/SKILL.md` from this file's directory. There is no Skill tool, and `pgsql-coding:pgsql-jsonb` is not a path. | 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 names `/pgsql-query` and tells the subagent to read that skill's `SKILL.md` from the plugin install. ## 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..2703d04 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.** From this skill's directory (the folder that contains this file, in the plugin install), run `python3 scripts/plan-summary.py plan.txt` (or `-` for stdin). On Claude Code the Skill tool prints that directory. On Cursor use the directory of the loaded `/pgsql-performance` skill. The script is not under `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 From 9756c282bfb62b1a35902ac02a4edc7aa8c021f9 Mon Sep 17 00:00:00 2001 From: Sebastian Iancu Date: Tue, 27 Oct 2026 14:37:47 +0100 Subject: [PATCH 2/3] fix(hooks): name the skill's install path on Cursor; test what PR #3 changed - session-start, skill-nudge and lint-on-save tell Cursor the absolute SKILL.md path under the plugin install (JSON-escaped), since "read /pgsql-jsonb" reads as a filesystem path and a subagent cannot find the install itself. One skill list serves both banners, so the routing check covers Cursor's too. Claude Code output is unchanged. - skill-nudge reads JSON line breaks in the added text as spaces, so a keyword on a later line of a multi-line edit still nudges (on both hosts), and marks a skill as nudged only after the message is out. - validate.py fails an edit hook whose matcher misses a host's edit tool (Claude Write|Edit, Cursor Write|StrReplace), with a selftest. - hooks-test.sh: Claude wording in the banner and lint-on-save, every skill and the install path in the Cursor banner, a quote in that path, lint-on-save's relative and absolute Cursor paths, an absolute path beside CURSOR_PROJECT_DIR, multi-line edits, and the relative path case run with CURSOR_PROJECT_DIR really unset. Co-Authored-By: Claude Opus 5.5 --- hooks/lint-on-save.sh | 14 ++++++++----- hooks/session-start.sh | 8 ++++++-- hooks/skill-nudge.sh | 32 ++++++++++++++++++----------- scripts/hooks-test.sh | 46 ++++++++++++++++++++++++++++++++++++------ scripts/validate.py | 20 +++++++++++++++--- 5 files changed, 92 insertions(+), 28 deletions(-) diff --git a/hooks/lint-on-save.sh b/hooks/lint-on-save.sh index 820b85e..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 or StrReplace (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. @@ -61,7 +62,10 @@ out="$(printf '%s\n' "$out" | sed -e "s/${esc}\[[0-9;]*m//g" | grep -E ':[0-9]+: [ -n "$out" ] || exit 0 if [ "$cursor" -eq 1 ]; then - how="read /pgsql-migrations" + # 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 diff --git a/hooks/session-start.sh b/hooks/session-start.sh index 7ece36a..475ae27 100755 --- a/hooks/session-start.sh +++ b/hooks/session-start.sh @@ -33,10 +33,14 @@ uses_postgres() { if uses_postgres; then lint=" · /pgsql-lint-setup adds squawk (no .squawk.toml found)" [ -e .squawk.toml ] && lint="" - # Fixed literals with no quotes or backslashes, so the JSON form needs no escaping. + # 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 - line="› PostgreSQL workspace: pgsql-coding (PostgreSQL 18 floor, 19 as hints). Open /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." + # 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 diff --git a/hooks/skill-nudge.sh b/hooks/skill-nudge.sh index 23a6734..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 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. +# 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,7 +10,8 @@ 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/')" @@ -44,6 +45,9 @@ 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"; } @@ -78,11 +82,13 @@ 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. +# 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 - how="read /${skill}" + # 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 @@ -91,12 +97,14 @@ msg="› pgsql-coding: ${what} touches ${topic}; ${how} before continuing." 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/scripts/hooks-test.sh b/scripts/hooks-test.sh index 05a7c02..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,11 +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' -chk "Cursor banner names a slash skill" "$out" "/pgsql-query" +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 @@ -112,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; read /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 @@ -129,15 +139,34 @@ p_cursor_edit() { # CONVERSATION FILE NEW 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 /pgsql-indexing' +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="$(printf '%s' "$(p_cursor_edit "$s8" "rel.sql" 'SELECT 1;')" | env -u CLAUDE_PLUGIN_ROOT bash "$here/hooks/skill-nudge.sh")" +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 /pgsql-query' +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 @@ -158,16 +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 slash skill" "$out" "read /pgsql-migrations" +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 a5bbbe8..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 - relative to the plugin install 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"), @@ -375,6 +378,14 @@ def validate_hook_parity(): 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 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,)), From 370957485258396015819e5bbefd562edde5c13e Mon Sep 17 00:00:00 2001 From: Sebastian Iancu Date: Tue, 27 Oct 2026 14:37:48 +0100 Subject: [PATCH 3/3] fix: Cursor skill paths that resolve, and host claims with sources - Agents: read /skills//SKILL.md on Cursor (the old reviewer path pointed into agents/); the summariser runs by its full path, with the Claude Code fallback back; the reviewer loads only the skills the change touches; "contracts you keep, not a sandbox" is back, since Bash and psql can write on either host. - pgsql-performance: run the summariser by its full path, so a relative plan file resolves in the workspace. - Router, Cursor rule, README briefs: a Cursor brief carries the absolute SKILL.md path; the rule's squawk.toml line no longer says to search for it. - docs/authoring.md is the one home for what each host enforces, with sources: Cursor documents readonly: true, not permissionMode; Claude Code ignores permissionMode in plugin agents. testing.md adds a Cursor check that settles it. - CHANGELOG: no Changed group while nothing is released. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 8 ++++---- CHANGELOG.md | 7 ------- README.md | 8 ++++---- agents/pgsql-plan-analyst.md | 6 +++--- agents/pgsql-reviewer.md | 4 ++-- docs/authoring.md | 4 ++-- docs/install.md | 6 +++--- docs/testing.md | 11 ++++++----- rules/pgsql-context.mdc | 10 +++++----- skills/pgsql-coding/SKILL.md | 4 ++-- skills/pgsql-performance/SKILL.md | 2 +- 11 files changed, 32 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a31910d..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|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`. A relative tool path is joined to `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`, 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 `/`. 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 read the skills (Claude Code: the Skill tool `pgsql-coding:`; Cursor: `/` and the skill file in the plugin install), never edit, never run DML, and dispatch no sub-agents. Both declare `tools:` (Claude enforces it; Cursor does not) and `permissionMode: readonly` (what Cursor plugin agents enforce). Never `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) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 04b8137..1787c9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,10 +20,3 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Evals: one case per focused skill under `evals/`. - Scripts: `scripts/validate.py` (with `--selftest` and `--check-links`), `scripts/validate.sh`, `scripts/hooks-test.sh`. - Docs: `docs/install.md`, `docs/testing.md`, `docs/versioning.md`, `docs/authoring.md`; CI workflows `validate.yml` and `links.yml`. - -### Changed -- Hooks: Cursor `postToolUse` matchers are `Write|StrReplace`; a relative path is joined to `CURSOR_PROJECT_DIR`; Cursor nudges name `/` and Claude Code keeps `pgsql-coding:`. -- Skills: the router, `pgsql-performance`'s plan summariser and `/pgsql-lint-setup` say how Cursor opens a skill and where that skill's files live in the plugin install. -- Agents: `permissionMode: readonly` on `pgsql-reviewer` and `pgsql-plan-analyst`; both read skills from the plugin install on Cursor and still use the Skill tool on Claude Code. -- Cursor rule: `pgsql-context.mdc` attaches to `**/*.sql` only, and the plan summariser path is the installed `pgsql-performance` skill. -- Docs: Cursor hook commands are described as relative to the plugin install. diff --git a/README.md b/README.md index 4e21d9a..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. Reads the skills, returns severity-ranked findings, never edits files or runs DML, and dispatches no sub-agents. Cursor sets `permissionMode: readonly`. | -| 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. Cursor sets `permissionMode: readonly`. | +| 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, 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, open `/pgsql-coding` and then `/pgsql-query` (or the sibling the change needs) and read that skill's `SKILL.md` from the plugin install. 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, open `/pgsql-coding` and the focused `/pgsql-*` skills and read each `SKILL.md` from the plugin install. 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 34ea6d3..c1349ed 100644 --- a/agents/pgsql-plan-analyst.md +++ b/agents/pgsql-plan-analyst.md @@ -23,7 +23,7 @@ tools: - Skill --- -You are **pgsql-plan-analyst**, a report-only analyst of PostgreSQL `EXPLAIN` plans (PostgreSQL 18; 19 additions are hints). You work in your own context so a long plan never fills the caller's, and you return a short, ranked diagnosis. `Bash` is for the plan summariser and read-only `psql`. Cursor enforces no edits through `permissionMode: readonly` (plugin agents ignore `readonly: true` and do not enforce `tools:`). Claude Code enforces the `tools:` list. Where a host enforces neither, no-edit and no-write are contracts you keep. +You are **pgsql-plan-analyst**, a report-only analyst of PostgreSQL `EXPLAIN` plans (PostgreSQL 18; 19 additions are hints). You work in your own context so a long plan never fills the caller's, and you return a short, ranked diagnosis. `Bash` is for the plan summariser and read-only `psql`; no-edit and no-write are contracts you keep, not a sandbox that enforces them. ## When to invoke @@ -40,8 +40,8 @@ You are **pgsql-plan-analyst**, a report-only analyst of PostgreSQL `EXPLAIN` pl ## How to analyse -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, read `skills/pgsql-performance/SKILL.md` from the plugin install (next to this agent file: `../skills/pgsql-performance/SKILL.md`) and the same `../skills//SKILL.md` pattern for `pgsql-indexing` and `pgsql-jsonb`. -2. **Run the summariser before reading any plan.** The script is `scripts/plan-summary.py` in the `pgsql-performance` skill directory. On Claude Code that is the directory the Skill tool printed. On Cursor it is `../skills/pgsql-performance/scripts/plan-summary.py` relative to this agent file. Do not run `skills/pgsql-performance/scripts/plan-summary.py` from 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. +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 50beb0d..08492aa 100644 --- a/agents/pgsql-reviewer.md +++ b/agents/pgsql-reviewer.md @@ -23,7 +23,7 @@ tools: - Skill --- -You are **pgsql-reviewer**, a report-only reviewer of SQL for PostgreSQL 18 (PostgreSQL 19 additions are hints, not requirements). You supply the judgment that survives the test suite and `squawk`. `Bash` is for `git diff`, `squawk` and read-only `psql`. Cursor enforces no edits through `permissionMode: readonly` (plugin agents ignore `readonly: true` and do not enforce `tools:`). Claude Code enforces the `tools:` list. Where a host enforces neither, no-edit and no-write are contracts you keep. +You are **pgsql-reviewer**, a report-only reviewer of SQL for PostgreSQL 18 (PostgreSQL 19 additions are hints, not requirements). You supply the judgment that survives the test suite and `squawk`. `Bash` is for `git diff`, `squawk` and read-only `psql`; no-edit and no-write are contracts you keep, not a sandbox that enforces them. ## When to invoke @@ -42,7 +42,7 @@ You are **pgsql-reviewer**, a report-only reviewer of SQL for PostgreSQL 18 (Pos ## How to review -1. **Load the skills.** On Claude Code, invoke the Skill tool with `pgsql-coding:pgsql-coding`, then `pgsql-coding:pgsql-query` and the same prefix for each focused skill the change touches. On Cursor there is no Skill tool: read `skills//SKILL.md` from the plugin install (the directory that holds this agent file, or the path Cursor gave you for `/pgsql-query`). The skills are `pgsql-query`, `pgsql-jsonb`, `pgsql-schema`, `pgsql-indexing`, `pgsql-performance`, `pgsql-migrations`, per the router's *Route, then load* table. Read the references a finding depends on before you assert it. If those files cannot be opened, 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 ff7e818..458e27e 100644 --- a/docs/authoring.md +++ b/docs/authoring.md @@ -12,7 +12,7 @@ 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`. 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 does not enforce `tools:` and has no Skill tool: the body tells it to read `skills//SKILL.md` in the plugin install, or to open `/`. Report-only agents set `permissionMode: readonly`, which is the key Cursor plugin agents honor (`readonly: true` is ignored there). +- **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). 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:`, and Cursor takes `/` plus a read of that skill's `SKILL.md` in the plugin install. +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 0e5eff9..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` 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 to read `/pgsql-migrations`; 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. 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 to read `/`; Claude Code is told to load `pgsql-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|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 e8a0728..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 and in Cursor's `StrReplace` `new_string`; the whole file when a Cursor payload has no added-text field; a relative Cursor path joined to `CURSOR_PROJECT_DIR`; once per skill per session (`session_id` or `conversation_id`); `hookSpecificOutput.additionalContext` under Claude Code (`pgsql-coding:`), `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 (it names `/pgsql-query`) 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 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/rules/pgsql-context.mdc b/rules/pgsql-context.mdc index 9615372..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. It attaches to `.sql` files only. SQL inside a `.go` or `.php` file is not covered by this rule; open `/pgsql-query` or the sibling skill the change needs. 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. It attaches to `.sql` ## Route, then load -This rule is an index, not the standard, and it is in context only for `.sql` files. Before writing or reviewing SQL, open the focused skill matching the change (`/pgsql-query`, and the same `/` prefix for each name below). On disk that file is `skills//SKILL.md` in the plugin install, not in the repository. 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, and it is in context only for `.sql` fi ## Reading a plan -Summarise a plan before reading it. The script is `scripts/plan-summary.py` in the installed `pgsql-performance` skill (the directory of that skill's `SKILL.md`), not `skills/pgsql-performance/` in the repository. Run `python3 "/scripts/plan-summary.py" plan.txt`. 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/skills/pgsql-coding/SKILL.md b/skills/pgsql-coding/SKILL.md index 7010ef0..d7f31ee 100644 --- a/skills/pgsql-coding/SKILL.md +++ b/skills/pgsql-coding/SKILL.md @@ -28,7 +28,7 @@ Route the PostgreSQL task to the skill and the tool that own it. This skill is a 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.** Open `/pgsql-jsonb` (the same `/` prefix for every skill in the table). Its files are in the plugin install, next to this skill: read `../pgsql-jsonb/SKILL.md` from this file's directory. There is no Skill tool, and `pgsql-coding:pgsql-jsonb` is not a path. +- **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 | |---|---| @@ -41,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. On Claude Code the brief names `pgsql-coding:pgsql-query`. On Cursor it names `/pgsql-query` and tells the subagent to read that skill's `SKILL.md` from the plugin install. +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 diff --git a/skills/pgsql-performance/SKILL.md b/skills/pgsql-performance/SKILL.md index 2703d04..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.** From this skill's directory (the folder that contains this file, in the plugin install), run `python3 scripts/plan-summary.py plan.txt` (or `-` for stdin). On Claude Code the Skill tool prints that directory. On Cursor use the directory of the loaded `/pgsql-performance` skill. The script is not under `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. +- **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