cursordoctrine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL.md +113 -0
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/bin/cli.mjs +413 -0
- package/linux/USER-RULES.md +12 -0
- package/linux/doctrine.md +172 -0
- package/linux/hooks/anti-slop-audit.sh +163 -0
- package/linux/hooks/anti-slop.md +56 -0
- package/linux/hooks/final-review.md +52 -0
- package/linux/hooks/final-review.sh +99 -0
- package/linux/hooks/hook-common.sh +120 -0
- package/linux/hooks/minimal-edit-audit.sh +112 -0
- package/linux/hooks/permission-gate.sh +75 -0
- package/linux/hooks/post-tool-use.sh +53 -0
- package/linux/hooks/self-review-trigger.sh +56 -0
- package/linux/hooks/self-review.md +48 -0
- package/linux/hooks/subagent-stop-review.sh +93 -0
- package/linux/hooks.json +64 -0
- package/linux/inject-doctrine.sh +31 -0
- package/package.json +40 -0
- package/skills/anti-slop/SKILL.md +267 -0
- package/skills/anti-slop/scripts/scan_slop.py +986 -0
- package/windows/USER-RULES.md +12 -0
- package/windows/doctrine.md +172 -0
- package/windows/hooks/anti-slop-audit.ps1 +182 -0
- package/windows/hooks/anti-slop.md +56 -0
- package/windows/hooks/final-review.md +52 -0
- package/windows/hooks/final-review.ps1 +105 -0
- package/windows/hooks/hook-common.ps1 +84 -0
- package/windows/hooks/minimal-edit-audit.ps1 +116 -0
- package/windows/hooks/permission-gate.ps1 +98 -0
- package/windows/hooks/post-tool-use.ps1 +46 -0
- package/windows/hooks/self-review-trigger.ps1 +83 -0
- package/windows/hooks/self-review.md +48 -0
- package/windows/hooks/subagent-stop-review.ps1 +89 -0
- package/windows/hooks.json +64 -0
- package/windows/inject-doctrine.ps1 +58 -0
package/INSTALL.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Agent prompt: install the cursordoctrine hooks
|
|
2
|
+
|
|
3
|
+
> Fast path: if Node 18+ is on the machine, `npx cursordoctrine@latest install`
|
|
4
|
+
> does step 2 (including the hooks.json merge), and `npx cursordoctrine verify`
|
|
5
|
+
> does step 3. Then restart Cursor and continue from step 4. The prompt below
|
|
6
|
+
> is the manual path for machines without Node.
|
|
7
|
+
|
|
8
|
+
> Paste everything below this line into a Cursor agent chat on the target machine.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
Install the cursordoctrine hook package for Cursor on this machine, then verify it works. This package is for Cursor only — do not wire it into any other tool, project, or editor config.
|
|
13
|
+
|
|
14
|
+
If `node --version` shows Node 18 or newer, prefer the npm installer: run `npx cursordoctrine@latest install`, then `npx cursordoctrine verify`, then skip to step 4. Otherwise continue with the manual steps.
|
|
15
|
+
|
|
16
|
+
## 1. Ask the user which system they are on
|
|
17
|
+
|
|
18
|
+
Before touching anything, ask the user one question and wait for the answer:
|
|
19
|
+
|
|
20
|
+
> "Are you installing on **Windows** or **Linux** (including SSH remotes)?"
|
|
21
|
+
|
|
22
|
+
Do not guess from the shell you happen to be running in — a Windows machine driving an SSH remote needs the Linux package on the remote. Use the answer to pick the folder:
|
|
23
|
+
|
|
24
|
+
- **Windows** → use the `windows/` folder of this repo (PowerShell hooks, run with `pwsh.exe`).
|
|
25
|
+
- **Linux** → use the `linux/` folder (bash hooks).
|
|
26
|
+
|
|
27
|
+
Check the prerequisites first:
|
|
28
|
+
|
|
29
|
+
- Windows: PowerShell 7 (`pwsh`) on PATH, plus `git`. Python 3.9+ if you want the anti-slop scanner (the hooks work without it).
|
|
30
|
+
- Linux: `bash`, `git`, and either `jq` or `python3` (the hooks prefer `jq` and fall back to `python3`; install `jq` if neither is present). Python 3.9+ for the anti-slop scanner.
|
|
31
|
+
|
|
32
|
+
## 2. Copy the files
|
|
33
|
+
|
|
34
|
+
Windows (from the repo root, in pwsh):
|
|
35
|
+
|
|
36
|
+
```powershell
|
|
37
|
+
New-Item -ItemType Directory -Force "$HOME\.agents\hooks", "$HOME\.cursor" | Out-Null
|
|
38
|
+
Copy-Item windows\hooks\* "$HOME\.agents\hooks\" -Force
|
|
39
|
+
Copy-Item windows\inject-doctrine.ps1, windows\doctrine.md, windows\USER-RULES.md "$HOME\.cursor\" -Force
|
|
40
|
+
# hooks.json ships with ~/ placeholders; pwsh -File does NOT expand ~, so
|
|
41
|
+
# substitute the real profile path (forward slashes) at install time:
|
|
42
|
+
$h = $HOME -replace '\\', '/'
|
|
43
|
+
(Get-Content windows\hooks.json -Raw).Replace('~/', "$h/") | Set-Content "$HOME\.cursor\hooks.json" -NoNewline
|
|
44
|
+
# anti-slop skill (SKILL.md + the scanner the final-review hook runs):
|
|
45
|
+
New-Item -ItemType Directory -Force "$HOME\.cursor\skills" | Out-Null
|
|
46
|
+
Copy-Item skills\anti-slop "$HOME\.cursor\skills\" -Recurse -Force
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Linux (from the repo root, in bash):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
mkdir -p ~/.agents/hooks ~/.cursor
|
|
53
|
+
cp linux/hooks/* ~/.agents/hooks/
|
|
54
|
+
cp linux/inject-doctrine.sh linux/doctrine.md linux/USER-RULES.md ~/.cursor/
|
|
55
|
+
cp linux/hooks.json ~/.cursor/hooks.json
|
|
56
|
+
chmod +x ~/.agents/hooks/*.sh ~/.cursor/inject-doctrine.sh
|
|
57
|
+
# anti-slop skill (SKILL.md + the scanner the final-review hook runs):
|
|
58
|
+
mkdir -p ~/.cursor/skills
|
|
59
|
+
cp -r skills/anti-slop ~/.cursor/skills/
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
If `~/.cursor/hooks.json` already exists, merge the hook entries instead of overwriting — preserve anything the user already has.
|
|
63
|
+
|
|
64
|
+
## 3. Verify before restarting Cursor
|
|
65
|
+
|
|
66
|
+
Run each hook by hand with a fake payload and confirm the output. Every hook must exit 0; none of them may hang.
|
|
67
|
+
|
|
68
|
+
Linux:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
echo '{"command":"git push --force"}' | bash ~/.agents/hooks/permission-gate.sh # expect "permission":"deny"
|
|
72
|
+
echo '{"command":"git status"}' | bash ~/.agents/hooks/permission-gate.sh # expect {"permission":"allow"}
|
|
73
|
+
echo '{"conversation_id":"t1","file_path":"/tmp/x.py"}' | bash ~/.agents/hooks/self-review-trigger.sh
|
|
74
|
+
echo '{"conversation_id":"t1"}' | bash ~/.agents/hooks/post-tool-use.sh # expect {"additional_context": ...}
|
|
75
|
+
echo '{"conversation_id":"t1","status":"completed"}' | bash ~/.agents/hooks/final-review.sh # expect {"followup_message": ...} once, then {}
|
|
76
|
+
echo '{"conversation_id":"t2","file_path":"/tmp/x.py"}' | bash ~/.agents/hooks/self-review-trigger.sh
|
|
77
|
+
echo '{"conversation_id":"t2","status":"completed"}' | bash ~/.agents/hooks/subagent-stop-review.sh # expect {"followup_message": "SUBAGENT FINAL REVIEW ..."} once, then {}
|
|
78
|
+
echo '{"conversation_id":"t2"}' | bash ~/.agents/hooks/post-tool-use.sh # drain t2's leftover feedback file
|
|
79
|
+
echo '{}' | bash ~/.cursor/inject-doctrine.sh # expect {"additional_context": ...}
|
|
80
|
+
python3 ~/.cursor/skills/anti-slop/scripts/scan_slop.py --help # expect usage text (final review's scanner)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
If the scanner check fails, the final review still works — it falls back to the
|
|
84
|
+
`~/.agents/hooks/anti-slop.md` checklist — but re-run the copy step above. The
|
|
85
|
+
skill itself (`~/.cursor/skills/anti-slop/SKILL.md`) needs Python 3.9+ for the
|
|
86
|
+
scanner and nothing else.
|
|
87
|
+
|
|
88
|
+
Windows (same payloads, swap `bash ~/...sh` for `pwsh.exe -NoProfile -File $HOME\.agents\hooks\<name>.ps1`, `inject-doctrine.ps1` lives in `$HOME\.cursor`, and use `python` instead of `python3` for the scanner check):
|
|
89
|
+
|
|
90
|
+
```powershell
|
|
91
|
+
echo '{"command":"git push --force"}' | pwsh.exe -NoProfile -File $HOME\.agents\hooks\permission-gate.ps1
|
|
92
|
+
echo '{"conversation_id":"t2","file_path":"C:\tmp\x.py"}' | pwsh.exe -NoProfile -File $HOME\.agents\hooks\self-review-trigger.ps1
|
|
93
|
+
echo '{"conversation_id":"t2","status":"completed"}' | pwsh.exe -NoProfile -File $HOME\.agents\hooks\subagent-stop-review.ps1 # SUBAGENT FINAL REVIEW once, then {}
|
|
94
|
+
python $HOME\.cursor\skills\anti-slop\scripts\scan_slop.py --help
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Also validate the config: `~/.cursor/hooks.json` must parse as JSON.
|
|
98
|
+
|
|
99
|
+
## 4. Verify inside Cursor
|
|
100
|
+
|
|
101
|
+
1. Restart Cursor (hooks.json is read at startup).
|
|
102
|
+
2. Open any project and start a new agent chat. The doctrine should be in context — ask the agent "what does your doctrine say about diffs?" and it should answer from §2.
|
|
103
|
+
3. Have the agent make a small edit to a tracked file. On the next turn it should receive a `SELF-REVIEW TRIGGER` message.
|
|
104
|
+
4. Ask the agent to run `git push --force` (in a throwaway repo). The permission gate must block it.
|
|
105
|
+
5. Finish a small implementation and stop. A single `FINAL REVIEW` follow-up should fire — exactly once.
|
|
106
|
+
6. Delegate a small edit to a subagent (e.g. ask the agent to "use a generalPurpose subagent to add a comment to <file>"). The subagent should receive one `SUBAGENT FINAL REVIEW` follow-up before returning, and the parent should see `SUBAGENT WORK DETECTED` at its next tool boundary. (`subagentStop` is only read at startup — if nothing fires, restart Cursor again.)
|
|
107
|
+
7. Type `/anti-slop` in a chat (or say "remove the AI slop") — the anti-slop skill should load and run the scanner as its first step.
|
|
108
|
+
|
|
109
|
+
## 5. Report
|
|
110
|
+
|
|
111
|
+
Tell the user what was installed, which checks passed, and anything that failed with the exact error. Do not silently work around a failing check.
|
|
112
|
+
|
|
113
|
+
Kill switches if something misbehaves: `HOOKS_ENFORCE=0` (everything advisory off), `PERM_GATE_ENFORCE=0`, `MINIMAL_EDITING_ENFORCE=0`, `ANTI_SLOP_ENFORCE=0`, `FINAL_REVIEW_ENFORCE=0`, `SUBAGENT_REVIEW_ENFORCE=0`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mario Pulice (kleosr)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# cursordoctrine
|
|
2
|
+
|
|
3
|
+
Thin self-review hooks for Cursor. Five hook events, one message bus. The model is the auditor Cursor only carries context and gates blast radius.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
A small set of Cursor hooks that make the agent review its own work without bolting a static-analysis pipeline onto every keystroke. There is no regex army and no scoring engine. The hooks do three jobs:
|
|
8
|
+
|
|
9
|
+
1. **Inject the doctrine** at session start, so every chat begins with the same short governing text (`doctrine.md` + `USER-RULES.md`).
|
|
10
|
+
2. **Hand the model its own edits back.** After each agent edit, a self-review prompt (plus minimal-edit and anti-slop advisories when they trip) is stashed and delivered on the next turn. The model reads its own diff, fixes real bugs, and stays quiet otherwise.
|
|
11
|
+
3. **Gate blast radius.** One permission gate denies a short, explicit list of dangerous commands (`rm -rf /`, `curl | sh`, force-push, `npm publish`, ...). Everything else is allowed.
|
|
12
|
+
|
|
13
|
+
When an implementation finishes, a stop hook fires exactly one final review pass over everything that changed — then stops. Delegated work gets the same treatment: a subagent that edited files reviews its own implementation before its result returns to the parent, and its edits are folded into the parent's final review. Every bound is enforced twice: in the script and in `hooks.json`.
|
|
14
|
+
|
|
15
|
+
This setup is for Cursor only. It installs into `~/.cursor` and `~/.agents/hooks` and touches nothing in your projects.
|
|
16
|
+
|
|
17
|
+
## Layout
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
windows/ PowerShell hooks (pwsh) — install on Windows machines
|
|
21
|
+
hooks.json hook wiring for ~/.cursor/hooks.json
|
|
22
|
+
inject-doctrine.ps1, doctrine.md, USER-RULES.md
|
|
23
|
+
hooks/ the eight scripts + the three prompt files
|
|
24
|
+
linux/ bash hooks — install on Linux machines and SSH remotes
|
|
25
|
+
hooks.json, inject-doctrine.sh, doctrine.md, USER-RULES.md
|
|
26
|
+
hooks/ same hooks, ported to bash (jq preferred, python3 fallback)
|
|
27
|
+
skills/ Cursor agent skills shipped with the package
|
|
28
|
+
anti-slop/ SKILL.md + the duplication scanner (final review runs it)
|
|
29
|
+
bin/ the npm CLI (npx cursordoctrine install / verify / uninstall)
|
|
30
|
+
INSTALL.md a ready-to-paste prompt that tells a Cursor agent to
|
|
31
|
+
install the right folder and verify every hook
|
|
32
|
+
assets/ the architecture diagram above
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The two folders are functionally identical. Windows runs everything through `pwsh.exe`; Linux runs bash, which is what you want on a remote you reach over SSH (see your `~/.ssh/config` host — the hooks live on the remote's `$HOME`, not on your laptop).
|
|
36
|
+
|
|
37
|
+
## The five flows
|
|
38
|
+
|
|
39
|
+
| Flow | Event | What happens |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| Session | `sessionStart` | `inject-doctrine` reads the doctrine + user rules and emits them as `additional_context`. |
|
|
42
|
+
| Every turn | `postToolUse` | Folds completed subagents' edit markers into this conversation's marker, then drains the conversation's pending feedback file into `additional_context`. One-shot, keyed by conversation id. |
|
|
43
|
+
| Shell | `beforeShellExecution` | `permission-gate` checks the command against a deny list. Allow by default, deny by list, fail open. |
|
|
44
|
+
| Edit | `afterFileEdit` + `stop` | `self-review-trigger` stashes the review prompt per edit; `minimal-edit-audit` and `anti-slop-audit` append advisories when thresholds trip; `final-review` fires one end-of-implementation pass. |
|
|
45
|
+
| Subagent | `subagentStop` | `subagent-stop-review` fires one in-subagent final review when a delegated run edited files, before the result returns to the parent. Marker-gated and flag-braked like `final-review`. |
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
The fast path is npm (Node 18+):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx cursordoctrine@latest install # copies the hook pack into ~/.agents/hooks + ~/.cursor, merges hooks.json
|
|
53
|
+
npx cursordoctrine verify # smoke-tests every hook with fake payloads, no restart needed
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Then restart Cursor — `hooks.json` is read at startup. `install` is idempotent: re-run it to update, and entries you added to `~/.cursor/hooks.json` yourself are preserved. `npx cursordoctrine uninstall` removes the pack the same way.
|
|
57
|
+
|
|
58
|
+
No Node? Open `INSTALL.md`, paste its contents into a Cursor agent chat on the target machine, and let the agent copy the files and run the verification checklist. Or do it by hand — the copy commands are in the same file.
|
|
59
|
+
|
|
60
|
+
Prerequisites: `git` everywhere; `pwsh` on Windows; `bash` plus `jq` or `python3` on Linux.
|
|
61
|
+
|
|
62
|
+
The anti-slop skill (`skills/anti-slop/` — SKILL.md and the duplication scanner) installs to `~/.cursor/skills/anti-slop/`. The final review runs the scanner from there; if it's missing (an install from before it shipped), the review falls back to the `~/.agents/hooks/anti-slop.md` checklist instead of failing.
|
|
63
|
+
|
|
64
|
+
## Tuning and kill switches
|
|
65
|
+
|
|
66
|
+
All hooks fail open and always exit 0. Nothing here can block your session.
|
|
67
|
+
|
|
68
|
+
| Variable | Default | Effect |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `HOOKS_ENFORCE=0` | on | turns off all advisory hooks at once |
|
|
71
|
+
| `PERM_GATE_ENFORCE=0` | on | disables the permission gate |
|
|
72
|
+
| `MINIMAL_EDITING_ENFORCE=0` | on | disables the over-edit advisory |
|
|
73
|
+
| `ANTI_SLOP_ENFORCE=0` | on | disables the slop advisory |
|
|
74
|
+
| `FINAL_REVIEW_ENFORCE=0` | on | disables the final review pass |
|
|
75
|
+
| `SUBAGENT_REVIEW_ENFORCE=0` | on | disables the in-subagent review pass |
|
|
76
|
+
| `MINIMAL_EDIT_WARN_LINES` / `MINIMAL_EDIT_FAIL_LINES` | 100 / 400 | over-edit thresholds |
|
|
77
|
+
| `ANTI_SLOP_CHECKLIST_LINES` | 40 | added-lines threshold for the checklist |
|
|
78
|
+
|
|
79
|
+
## Design notes
|
|
80
|
+
|
|
81
|
+
- **State lives under `$HOME`**, in `~/.cursor/.hooks-pending/`, keyed by conversation id. No repo litter, and concurrent sessions can't drain each other's prompts. Stale state older than 7 days is swept on every stop.
|
|
82
|
+
- **`afterFileEdit` output isn't consumed by Cursor**, so the edit hooks write to a pending file and `post-tool-use` re-emits it at the next tool boundary. That's the whole message bus.
|
|
83
|
+
- **One review per implementation.** The stop hook arms a per-conversation flag before emitting its follow-up, so a crash can't re-fire it and a long chat still gets a review after each implementation.
|
|
84
|
+
- **Subagents are first-class.** `afterFileEdit` fires inside subagents keyed by the *subagent's* conversation id, the harness normalizes agent edits (incl. `StrReplace`) to tool type `Write`, and `postToolUse` never fires for the `Task` tool — all verified by payload capture. So the matchers cover `Write|StrReplace|EditNotebook` defensively, `subagentStop` reviews the subagent in its own context, and the parent folds orphaned subagent markers (found via the `subagents/` transcript directory) into its own at every tool boundary and at stop.
|
|
85
|
+
|
|
86
|
+
Self-contained. No build. Open `hooks.json` and read it — it's the whole system in one file.
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cursordoctrine — one-command installer for the cursordoctrine hook pack.
|
|
3
|
+
//
|
|
4
|
+
// The payload ships inside this npm package (windows/, linux/, skills/).
|
|
5
|
+
// `install` copies it into $HOME exactly the way INSTALL.md step 2 does,
|
|
6
|
+
// merging ~/.cursor/hooks.json instead of overwriting it. `verify` smoke-tests
|
|
7
|
+
// every hook with fake payloads (INSTALL.md step 3). `uninstall` removes our
|
|
8
|
+
// files and strips our hooks.json entries while preserving foreign ones.
|
|
9
|
+
//
|
|
10
|
+
// Zero runtime dependencies. Node >= 18.
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
chmodSync,
|
|
14
|
+
cpSync,
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from 'node:fs';
|
|
22
|
+
import { join, resolve, dirname } from 'node:path';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'));
|
|
29
|
+
|
|
30
|
+
// CURSORDOCTRINE_HOME lets tests install into a sandbox home.
|
|
31
|
+
const HOME = process.env.CURSORDOCTRINE_HOME || homedir();
|
|
32
|
+
const platform = process.platform === 'win32' ? 'windows' : 'linux';
|
|
33
|
+
const payload = join(pkgRoot, platform);
|
|
34
|
+
|
|
35
|
+
const hooksDst = join(HOME, '.agents', 'hooks');
|
|
36
|
+
const cursorDst = join(HOME, '.cursor');
|
|
37
|
+
const skillSrc = join(pkgRoot, 'skills', 'anti-slop');
|
|
38
|
+
const skillDst = join(cursorDst, 'skills', 'anti-slop');
|
|
39
|
+
const pendingDir = join(cursorDst, '.hooks-pending');
|
|
40
|
+
const hooksJsonDst = join(cursorDst, 'hooks.json');
|
|
41
|
+
|
|
42
|
+
const injectName = platform === 'windows' ? 'inject-doctrine.ps1' : 'inject-doctrine.sh';
|
|
43
|
+
const doctrineFiles = [injectName, 'doctrine.md', 'USER-RULES.md'];
|
|
44
|
+
|
|
45
|
+
function payloadHookFiles() {
|
|
46
|
+
return readdirSync(join(payload, 'hooks'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// An entry in hooks.json is "ours" when its command references one of the
|
|
50
|
+
// script filenames we ship (hook scripts or the inject-doctrine script).
|
|
51
|
+
function ourKeys() {
|
|
52
|
+
return [...payloadHookFiles().filter((f) => !f.endsWith('.md')), injectName];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isOurs(command, keys) {
|
|
56
|
+
return typeof command === 'string' && keys.some((k) => command.includes(k));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function keyOf(command, keys) {
|
|
60
|
+
if (typeof command !== 'string') return undefined;
|
|
61
|
+
return keys.find((k) => command.includes(k));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function mergeHooks(existing, incoming, keys) {
|
|
65
|
+
const out = structuredClone(existing);
|
|
66
|
+
if (out.version === undefined) out.version = incoming.version;
|
|
67
|
+
if (!out.hooks || typeof out.hooks !== 'object' || Array.isArray(out.hooks)) out.hooks = {};
|
|
68
|
+
for (const [event, entries] of Object.entries(incoming.hooks || {})) {
|
|
69
|
+
if (!Array.isArray(out.hooks[event])) out.hooks[event] = [];
|
|
70
|
+
const cur = out.hooks[event];
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const k = keyOf(entry.command, keys);
|
|
73
|
+
const i = cur.findIndex((x) => x && keyOf(x.command, keys) === k && k !== undefined);
|
|
74
|
+
if (i >= 0) cur[i] = entry;
|
|
75
|
+
else cur.push(entry);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
let preserved = 0;
|
|
79
|
+
for (const entries of Object.values(out.hooks)) {
|
|
80
|
+
if (Array.isArray(entries)) preserved += entries.filter((x) => !isOurs(x?.command, keys)).length;
|
|
81
|
+
}
|
|
82
|
+
return { merged: out, preserved };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function install() {
|
|
86
|
+
console.log(`cursordoctrine ${pkg.version} — installing the ${platform} hook pack into ${HOME}`);
|
|
87
|
+
if (process.platform === 'darwin') {
|
|
88
|
+
console.log(' note: macOS detected — installing the Linux (bash) hook pack.');
|
|
89
|
+
}
|
|
90
|
+
if (platform === 'windows' && HOME.includes(' ')) {
|
|
91
|
+
console.log(' warning: home path contains spaces; hooks.json commands may need manual quoting.');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
mkdirSync(hooksDst, { recursive: true });
|
|
95
|
+
mkdirSync(cursorDst, { recursive: true });
|
|
96
|
+
|
|
97
|
+
const hookFiles = payloadHookFiles();
|
|
98
|
+
for (const f of hookFiles) cpSync(join(payload, 'hooks', f), join(hooksDst, f));
|
|
99
|
+
|
|
100
|
+
for (const f of doctrineFiles) cpSync(join(payload, f), join(cursorDst, f));
|
|
101
|
+
|
|
102
|
+
if (platform === 'linux') {
|
|
103
|
+
for (const f of hookFiles) {
|
|
104
|
+
if (f.endsWith('.sh')) chmodSync(join(hooksDst, f), 0o755);
|
|
105
|
+
}
|
|
106
|
+
chmodSync(join(cursorDst, injectName), 0o755);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// hooks.json: pwsh -File does not expand ~, so substitute the real profile
|
|
110
|
+
// path (forward slashes) on Windows — same as INSTALL.md. Bash expands ~.
|
|
111
|
+
let text = readFileSync(join(payload, 'hooks.json'), 'utf8');
|
|
112
|
+
if (platform === 'windows') {
|
|
113
|
+
text = text.replaceAll('~/', HOME.replaceAll('\\', '/') + '/');
|
|
114
|
+
}
|
|
115
|
+
const incoming = JSON.parse(text);
|
|
116
|
+
const keys = ourKeys();
|
|
117
|
+
|
|
118
|
+
let hooksJsonNote = 'written';
|
|
119
|
+
let result = incoming;
|
|
120
|
+
if (existsSync(hooksJsonDst)) {
|
|
121
|
+
let existing = null;
|
|
122
|
+
try {
|
|
123
|
+
existing = JSON.parse(readFileSync(hooksJsonDst, 'utf8'));
|
|
124
|
+
} catch {
|
|
125
|
+
const bak = `${hooksJsonDst}.bak-${Date.now()}`;
|
|
126
|
+
cpSync(hooksJsonDst, bak);
|
|
127
|
+
hooksJsonNote = `existing file was invalid JSON — backed up to ${bak}, wrote fresh`;
|
|
128
|
+
}
|
|
129
|
+
if (existing) {
|
|
130
|
+
const { merged, preserved } = mergeHooks(existing, incoming, keys);
|
|
131
|
+
result = merged;
|
|
132
|
+
hooksJsonNote = preserved > 0 ? `merged (${preserved} foreign entr${preserved === 1 ? 'y' : 'ies'} preserved)` : 'merged';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
writeFileSync(hooksJsonDst, JSON.stringify(result, null, 2) + '\n');
|
|
136
|
+
|
|
137
|
+
rmSync(skillDst, { recursive: true, force: true });
|
|
138
|
+
cpSync(skillSrc, skillDst, {
|
|
139
|
+
recursive: true,
|
|
140
|
+
filter: (src) => !src.includes('__pycache__'),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
console.log('');
|
|
144
|
+
console.log(` ~/.agents/hooks ${hookFiles.length} files`);
|
|
145
|
+
console.log(` ~/.cursor ${doctrineFiles.join(', ')}`);
|
|
146
|
+
console.log(` ~/.cursor/hooks.json ${hooksJsonNote}`);
|
|
147
|
+
console.log(' ~/.cursor/skills anti-slop (SKILL.md + scanner)');
|
|
148
|
+
console.log('');
|
|
149
|
+
|
|
150
|
+
const missing = prereqProblems();
|
|
151
|
+
for (const m of missing) console.log(` warning: ${m}`);
|
|
152
|
+
if (missing.length) console.log('');
|
|
153
|
+
|
|
154
|
+
console.log('Done. Restart Cursor — hooks.json is read at startup.');
|
|
155
|
+
console.log('Next: npx cursordoctrine verify');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function prereqProblems() {
|
|
159
|
+
const problems = [];
|
|
160
|
+
if (platform === 'windows') {
|
|
161
|
+
if (!canRun('pwsh.exe', ['-NoProfile', '-Command', 'exit 0'])) {
|
|
162
|
+
problems.push('PowerShell 7 (pwsh) not found on PATH — the hooks will not run until it is installed.');
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
if (!canRun('bash', ['-c', 'exit 0'])) {
|
|
166
|
+
problems.push('bash not found — the hooks will not run until it is installed.');
|
|
167
|
+
}
|
|
168
|
+
if (!canRun('jq', ['--version']) && !canRun('python3', ['-c', ''])) {
|
|
169
|
+
problems.push('neither jq nor python3 found — install one (the hooks prefer jq).');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!pythonCmd()) {
|
|
173
|
+
problems.push('Python 3.9+ not found — the anti-slop scanner is unavailable (the final review falls back to the checklist).');
|
|
174
|
+
}
|
|
175
|
+
return problems;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function canRun(cmd, args) {
|
|
179
|
+
const r = spawnSync(cmd, args, { timeout: 15000, windowsHide: true, stdio: 'ignore' });
|
|
180
|
+
return !r.error && r.status === 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function pythonCmd() {
|
|
184
|
+
for (const c of platform === 'windows' ? ['python', 'python3', 'py'] : ['python3', 'python']) {
|
|
185
|
+
if (canRun(c, ['-c', ''])) return c;
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function runHook(file, payloadObj) {
|
|
191
|
+
const cmd = platform === 'windows' ? ['pwsh.exe', '-NoProfile', '-File', file] : ['bash', file];
|
|
192
|
+
const r = spawnSync(cmd[0], cmd.slice(1), {
|
|
193
|
+
input: JSON.stringify(payloadObj),
|
|
194
|
+
encoding: 'utf8',
|
|
195
|
+
timeout: 20000,
|
|
196
|
+
windowsHide: true,
|
|
197
|
+
// Hooks resolve state under $HOME; pin it (and USERPROFILE, which pwsh
|
|
198
|
+
// derives $HOME from) so sandboxed verify runs stay self-contained.
|
|
199
|
+
env: { ...process.env, HOME, USERPROFILE: HOME },
|
|
200
|
+
});
|
|
201
|
+
if (r.error) return `spawn error: ${r.error.message}`;
|
|
202
|
+
return `${r.stdout || ''}${r.stderr || ''}`.trim();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function verify() {
|
|
206
|
+
console.log(`cursordoctrine ${pkg.version} — verifying the ${platform} hook pack in ${HOME}`);
|
|
207
|
+
console.log('');
|
|
208
|
+
|
|
209
|
+
if (!existsSync(hooksDst) || !existsSync(hooksJsonDst)) {
|
|
210
|
+
console.error('Not installed (missing ~/.agents/hooks or ~/.cursor/hooks.json).');
|
|
211
|
+
console.error('Run: npx cursordoctrine install');
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const ext = platform === 'windows' ? 'ps1' : 'sh';
|
|
216
|
+
const hook = (name) => join(hooksDst, `${name}.${ext}`);
|
|
217
|
+
const results = [];
|
|
218
|
+
const check = (name, fn) => {
|
|
219
|
+
let ok = false;
|
|
220
|
+
let detail = '';
|
|
221
|
+
try {
|
|
222
|
+
const r = fn();
|
|
223
|
+
ok = r === true || (typeof r === 'object' && r.ok);
|
|
224
|
+
detail = typeof r === 'object' && r.detail ? r.detail : '';
|
|
225
|
+
} catch (e) {
|
|
226
|
+
detail = e.message;
|
|
227
|
+
}
|
|
228
|
+
results.push({ name, ok, detail });
|
|
229
|
+
console.log(` ${ok ? ' ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
check('hooks.json parses as JSON', () => {
|
|
233
|
+
JSON.parse(readFileSync(hooksJsonDst, 'utf8'));
|
|
234
|
+
return true;
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
check('permission gate denies `git push --force`', () =>
|
|
238
|
+
/"permission"\s*:\s*"deny"/.test(runHook(hook('permission-gate'), { command: 'git push --force' })));
|
|
239
|
+
|
|
240
|
+
check('permission gate allows `git status`', () =>
|
|
241
|
+
/"permission"\s*:\s*"allow"/.test(runHook(hook('permission-gate'), { command: 'git status' })));
|
|
242
|
+
|
|
243
|
+
check('self-review trigger stashes + post-tool-use drains', () => {
|
|
244
|
+
runHook(hook('self-review-trigger'), { conversation_id: 'npxv1', file_path: join(HOME, 'x.py') });
|
|
245
|
+
return runHook(hook('post-tool-use'), { conversation_id: 'npxv1' }).includes('additional_context');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
check('final review fires once, then goes quiet', () => {
|
|
249
|
+
runHook(hook('self-review-trigger'), { conversation_id: 'npxv1', file_path: join(HOME, 'x.py') });
|
|
250
|
+
const first = runHook(hook('final-review'), { conversation_id: 'npxv1', status: 'completed' });
|
|
251
|
+
const second = runHook(hook('final-review'), { conversation_id: 'npxv1', status: 'completed' });
|
|
252
|
+
if (!first.includes('followup_message')) return { ok: false, detail: 'no followup_message on first stop' };
|
|
253
|
+
if (second.includes('followup_message')) return { ok: false, detail: 'review re-fired on second stop' };
|
|
254
|
+
return true;
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
check('subagent review fires once, then goes quiet', () => {
|
|
258
|
+
runHook(hook('self-review-trigger'), { conversation_id: 'npxv2', file_path: join(HOME, 'x.py') });
|
|
259
|
+
const first = runHook(hook('subagent-stop-review'), { conversation_id: 'npxv2', status: 'completed' });
|
|
260
|
+
const second = runHook(hook('subagent-stop-review'), { conversation_id: 'npxv2', status: 'completed' });
|
|
261
|
+
if (!first.includes('SUBAGENT FINAL REVIEW')) return { ok: false, detail: 'no SUBAGENT FINAL REVIEW on first stop' };
|
|
262
|
+
if (second.includes('followup_message')) return { ok: false, detail: 'review re-fired on second stop' };
|
|
263
|
+
return true;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
check('doctrine injection emits additional_context', () =>
|
|
267
|
+
runHook(join(cursorDst, injectName), {}).includes('additional_context'));
|
|
268
|
+
|
|
269
|
+
const py = pythonCmd();
|
|
270
|
+
const scanner = join(skillDst, 'scripts', 'scan_slop.py');
|
|
271
|
+
let scannerOk = false;
|
|
272
|
+
if (py && existsSync(scanner)) {
|
|
273
|
+
const r = spawnSync(py, [scanner, '--help'], { encoding: 'utf8', timeout: 20000, windowsHide: true });
|
|
274
|
+
scannerOk = !r.error && /usage/i.test(`${r.stdout || ''}${r.stderr || ''}`);
|
|
275
|
+
}
|
|
276
|
+
console.log(` ${scannerOk ? ' ok ' : 'warn'} anti-slop scanner --help${scannerOk ? '' : ' — unavailable (final review falls back to the checklist)'}`);
|
|
277
|
+
|
|
278
|
+
// Clean up verification state so the next real session starts fresh.
|
|
279
|
+
if (existsSync(pendingDir)) {
|
|
280
|
+
for (const f of readdirSync(pendingDir)) {
|
|
281
|
+
if (f.includes('npxv')) rmSync(join(pendingDir, f), { force: true });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const failed = results.filter((r) => !r.ok);
|
|
286
|
+
console.log('');
|
|
287
|
+
if (failed.length) {
|
|
288
|
+
console.error(`${failed.length} check(s) failed. Re-run: npx cursordoctrine install`);
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
console.log('All checks passed. Restart Cursor if you have not since installing.');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function uninstall() {
|
|
295
|
+
console.log(`cursordoctrine ${pkg.version} — removing the ${platform} hook pack from ${HOME}`);
|
|
296
|
+
|
|
297
|
+
const removed = [];
|
|
298
|
+
for (const f of payloadHookFiles()) {
|
|
299
|
+
const p = join(hooksDst, f);
|
|
300
|
+
if (existsSync(p)) {
|
|
301
|
+
rmSync(p, { force: true });
|
|
302
|
+
removed.push(`~/.agents/hooks/${f}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
for (const f of doctrineFiles) {
|
|
306
|
+
const p = join(cursorDst, f);
|
|
307
|
+
if (existsSync(p)) {
|
|
308
|
+
rmSync(p, { force: true });
|
|
309
|
+
removed.push(`~/.cursor/${f}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (existsSync(skillDst)) {
|
|
313
|
+
rmSync(skillDst, { recursive: true, force: true });
|
|
314
|
+
removed.push('~/.cursor/skills/anti-slop/');
|
|
315
|
+
}
|
|
316
|
+
if (existsSync(pendingDir)) {
|
|
317
|
+
rmSync(pendingDir, { recursive: true, force: true });
|
|
318
|
+
removed.push('~/.cursor/.hooks-pending/');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (existsSync(hooksJsonDst)) {
|
|
322
|
+
try {
|
|
323
|
+
const existing = JSON.parse(readFileSync(hooksJsonDst, 'utf8'));
|
|
324
|
+
const keys = ourKeys();
|
|
325
|
+
let foreign = 0;
|
|
326
|
+
for (const [event, entries] of Object.entries(existing.hooks || {})) {
|
|
327
|
+
if (!Array.isArray(entries)) continue;
|
|
328
|
+
existing.hooks[event] = entries.filter((x) => !isOurs(x?.command, keys));
|
|
329
|
+
foreign += existing.hooks[event].length;
|
|
330
|
+
if (existing.hooks[event].length === 0) delete existing.hooks[event];
|
|
331
|
+
}
|
|
332
|
+
if (foreign === 0) {
|
|
333
|
+
rmSync(hooksJsonDst, { force: true });
|
|
334
|
+
removed.push('~/.cursor/hooks.json');
|
|
335
|
+
} else {
|
|
336
|
+
writeFileSync(hooksJsonDst, JSON.stringify(existing, null, 2) + '\n');
|
|
337
|
+
removed.push(`~/.cursor/hooks.json (ours stripped, ${foreign} foreign entr${foreign === 1 ? 'y' : 'ies'} kept)`);
|
|
338
|
+
}
|
|
339
|
+
} catch {
|
|
340
|
+
console.log(' warning: ~/.cursor/hooks.json is not valid JSON — left untouched.');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
console.log('');
|
|
345
|
+
for (const r of removed) console.log(` removed ${r}`);
|
|
346
|
+
if (removed.length === 0) console.log(' nothing to remove.');
|
|
347
|
+
console.log('');
|
|
348
|
+
console.log('Done. Restart Cursor to unload the hooks.');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function help() {
|
|
352
|
+
console.log(`cursordoctrine ${pkg.version} — thin self-review hooks for Cursor; the model is the auditor
|
|
353
|
+
|
|
354
|
+
Usage
|
|
355
|
+
npx cursordoctrine <command>
|
|
356
|
+
|
|
357
|
+
Commands
|
|
358
|
+
install Install the hook pack, doctrine, and anti-slop skill into $HOME.
|
|
359
|
+
Merges ~/.cursor/hooks.json — entries you added yourself are preserved.
|
|
360
|
+
verify Smoke-test every installed hook with fake payloads (no Cursor restart needed).
|
|
361
|
+
uninstall Remove installed files and strip our hooks.json entries.
|
|
362
|
+
help Show this help.
|
|
363
|
+
|
|
364
|
+
After install
|
|
365
|
+
Restart Cursor — hooks.json is read at startup.
|
|
366
|
+
|
|
367
|
+
Examples
|
|
368
|
+
npx cursordoctrine@latest install
|
|
369
|
+
npx cursordoctrine verify
|
|
370
|
+
npx cursordoctrine uninstall
|
|
371
|
+
|
|
372
|
+
Kill switches (environment variables, all hooks fail open)
|
|
373
|
+
HOOKS_ENFORCE=0 everything advisory off
|
|
374
|
+
PERM_GATE_ENFORCE=0 permission gate off
|
|
375
|
+
MINIMAL_EDITING_ENFORCE=0 over-edit advisory off
|
|
376
|
+
ANTI_SLOP_ENFORCE=0 slop advisory off
|
|
377
|
+
FINAL_REVIEW_ENFORCE=0 final review off
|
|
378
|
+
SUBAGENT_REVIEW_ENFORCE=0 in-subagent review off
|
|
379
|
+
|
|
380
|
+
Docs https://github.com/kleosr/cursordoctrine`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const cmd = process.argv[2];
|
|
384
|
+
switch (cmd) {
|
|
385
|
+
case 'install':
|
|
386
|
+
case 'i':
|
|
387
|
+
install();
|
|
388
|
+
break;
|
|
389
|
+
case 'verify':
|
|
390
|
+
case 'check':
|
|
391
|
+
verify();
|
|
392
|
+
break;
|
|
393
|
+
case 'uninstall':
|
|
394
|
+
case 'remove':
|
|
395
|
+
case 'rm':
|
|
396
|
+
uninstall();
|
|
397
|
+
break;
|
|
398
|
+
case 'version':
|
|
399
|
+
case '--version':
|
|
400
|
+
case '-v':
|
|
401
|
+
console.log(pkg.version);
|
|
402
|
+
break;
|
|
403
|
+
case undefined:
|
|
404
|
+
case 'help':
|
|
405
|
+
case '--help':
|
|
406
|
+
case '-h':
|
|
407
|
+
help();
|
|
408
|
+
break;
|
|
409
|
+
default:
|
|
410
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
411
|
+
help();
|
|
412
|
+
process.exit(2);
|
|
413
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Doctrine (governing text) lives at ~/.cursor/doctrine.md and is loaded
|
|
2
|
+
at sessionStart. Read it once, internalize it; do not re-read it
|
|
3
|
+
mid-task. Its §1 (auditor), §2 (smallest correct diff), §3 (verify
|
|
4
|
+
then stop), §5 (ask don't guess), §8 (consistency anchor) are the only
|
|
5
|
+
meta-instructions that matter during a session.
|
|
6
|
+
|
|
7
|
+
When responding: be terse. No preamble, no postamble, no "I will now…".
|
|
8
|
+
One sharp clarifying question if the task is ambiguous, then proceed.
|
|
9
|
+
Reference code with `file_path:line_number` style.
|
|
10
|
+
|
|
11
|
+
Do not re-load skills, do not re-read the doctrine, do not run
|
|
12
|
+
gratuitous commands. If the answer is "I don't know", say so.
|