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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# minimal-edit-audit.ps1 - afterFileEdit minimal-editing advisory (Cursor).
|
|
2
|
+
#
|
|
3
|
+
# Audits the just-edited file for over-editing:
|
|
4
|
+
# * line-count - git diff --numstat thresholds (any language). Native git,
|
|
5
|
+
# no bash (Git's MSYS bash mangles Windows paths / lacks PATH
|
|
6
|
+
# when spawned from pwsh, so we compute this directly).
|
|
7
|
+
# * token metrics - audit-metrics.py (token-Levenshtein + cognitive
|
|
8
|
+
# complexity), Python files only, via a resolved interpreter.
|
|
9
|
+
# On WARN/FAIL it APPENDS a short advisory to the shared pending-feedback file;
|
|
10
|
+
# post-tool-use.ps1 delivers it as additional_context on the next tool turn.
|
|
11
|
+
#
|
|
12
|
+
# Advisory only: never blocks, never writes persistent state. afterFileEdit
|
|
13
|
+
# output isn't consumed and a non-zero exit shows as "hook failed", so we
|
|
14
|
+
# ALWAYS exit 0. Self-contained.
|
|
15
|
+
#
|
|
16
|
+
# Thresholds (env-overridable): MINIMAL_EDIT_FAIL_LINES (400), MINIMAL_EDIT_WARN_LINES (100).
|
|
17
|
+
# Disable: HOOKS_ENFORCE=0 or MINIMAL_EDITING_ENFORCE=0
|
|
18
|
+
|
|
19
|
+
$ErrorActionPreference = 'SilentlyContinue'
|
|
20
|
+
. "$PSScriptRoot\hook-common.ps1"
|
|
21
|
+
|
|
22
|
+
if ($env:HOOKS_ENFORCE -eq '0' -or $env:MINIMAL_EDITING_ENFORCE -eq '0') { exit 0 }
|
|
23
|
+
|
|
24
|
+
$obj = Read-HookStdinJson
|
|
25
|
+
if (-not $obj) { exit 0 }
|
|
26
|
+
|
|
27
|
+
# audit root = project from JSON (cwd, then workspace_roots), else CURSOR_PROJECT_DIR / HOME
|
|
28
|
+
$root = ''
|
|
29
|
+
$cands = @()
|
|
30
|
+
if ($obj.PSObject.Properties['cwd'] -and $obj.cwd) { $cands += [string]$obj.cwd }
|
|
31
|
+
if ($obj.PSObject.Properties['workspace_roots']) { foreach ($w in $obj.workspace_roots) { $cands += [string]$w } }
|
|
32
|
+
foreach ($c in $cands) { $f = ConvertTo-FwdPath $c; if ($f -and (Test-Path -LiteralPath $f)) { $root = $f.TrimEnd('/'); break } }
|
|
33
|
+
if (-not $root) { $root = (& { if ($env:CURSOR_PROJECT_DIR) { $env:CURSOR_PROJECT_DIR } else { $HOME } }).Replace('\', '/').TrimEnd('/') }
|
|
34
|
+
|
|
35
|
+
# edited file -> repo-relative forward-slash path
|
|
36
|
+
$fp = ''
|
|
37
|
+
foreach ($k in 'file_path', 'path', 'filename', 'absolute_path', 'abs_path') {
|
|
38
|
+
if ($obj.PSObject.Properties[$k] -and $obj.$k) { $fp = [string]$obj.$k; break }
|
|
39
|
+
}
|
|
40
|
+
if (-not $fp) { exit 0 }
|
|
41
|
+
$rel = ConvertTo-FwdPath $fp
|
|
42
|
+
if ($rel.StartsWith($root + '/', [System.StringComparison]::OrdinalIgnoreCase)) { $rel = $rel.Substring($root.Length + 1) }
|
|
43
|
+
if (Test-IsCursorConfigPath $fp) { exit 0 }
|
|
44
|
+
if (Test-IsCursorConfigPath $rel) { exit 0 }
|
|
45
|
+
|
|
46
|
+
# git repo?
|
|
47
|
+
& git -C $root rev-parse --git-dir 2>$null | Out-Null
|
|
48
|
+
if ($LASTEXITCODE -ne 0) { exit 0 }
|
|
49
|
+
|
|
50
|
+
# --- line-count audit (any language) via native git ----------------------
|
|
51
|
+
$failLines = if ($env:MINIMAL_EDIT_FAIL_LINES) { [int]$env:MINIMAL_EDIT_FAIL_LINES } else { 400 }
|
|
52
|
+
$warnLines = if ($env:MINIMAL_EDIT_WARN_LINES) { [int]$env:MINIMAL_EDIT_WARN_LINES } else { 100 }
|
|
53
|
+
$ins = 0; $del = 0
|
|
54
|
+
foreach ($line in (& git -C $root diff HEAD --numstat -- $rel 2>$null)) {
|
|
55
|
+
$parts = $line -split "`t"
|
|
56
|
+
if ($parts.Count -lt 3 -or $parts[0] -eq '-') { continue } # skip header/binary
|
|
57
|
+
$ins += [int]$parts[0]; $del += [int]$parts[1]
|
|
58
|
+
}
|
|
59
|
+
$changed = $ins + $del
|
|
60
|
+
|
|
61
|
+
$grade = 'OK'; $hint = ''
|
|
62
|
+
if ($changed -gt $failLines) { $grade = 'FAIL'; $hint = "$changed lines changed (limit $failLines) - likely over-editing; trim or split" }
|
|
63
|
+
elseif ($changed -gt $warnLines) { $grade = 'WARN'; $hint = "$changed lines changed - justify each hunk or split the task" }
|
|
64
|
+
|
|
65
|
+
# --- token metrics (.py only) via a resolved interpreter -----------------
|
|
66
|
+
$auditMetrics = Join-Path $HOME '.cursor\skills\minimal-editing\scripts\audit-metrics.py'
|
|
67
|
+
if ((Test-Path $auditMetrics) -and ($rel -match '\.py$')) {
|
|
68
|
+
# Windows often ships the interpreter as `py` or `python3`, not `python`.
|
|
69
|
+
# Without this resolution the call errored silently and .py metrics never ran.
|
|
70
|
+
$py = Get-Command python, python3, py -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
71
|
+
if ($py) {
|
|
72
|
+
$mout = & $py.Source $auditMetrics --root $root --format json --path $rel 2>$null
|
|
73
|
+
$mo = $null; try { $mo = ($mout -join "`n") | ConvertFrom-Json } catch { }
|
|
74
|
+
if ($mo -and $mo.grade) {
|
|
75
|
+
if ($mo.grade -eq 'FAIL') { $grade = 'FAIL' }
|
|
76
|
+
elseif ($mo.grade -eq 'WARN' -and $grade -eq 'OK') { $grade = 'WARN' }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if ($grade -eq 'OK') { exit 0 }
|
|
82
|
+
|
|
83
|
+
# --- compose advisory + append to the shared pending file ----------------
|
|
84
|
+
$hintTxt = if ($hint) { " - $hint" } else { '' }
|
|
85
|
+
if ($grade -eq 'FAIL') {
|
|
86
|
+
$actions = @"
|
|
87
|
+
- Trim every hunk that isn't required by the task.
|
|
88
|
+
- Prefer narrow, targeted edits over rewriting blocks.
|
|
89
|
+
- If the change is genuinely large, split it into smaller logical commits.
|
|
90
|
+
"@
|
|
91
|
+
} else {
|
|
92
|
+
$actions = ' Advisory only - trim unrelated hunks if any; otherwise proceed.'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
$msg = @"
|
|
96
|
+
Minimal-edit audit $grade - $rel
|
|
97
|
+
|
|
98
|
+
IMPORTANT: Try to preserve the original code and the logic of the original code as much as possible.
|
|
99
|
+
|
|
100
|
+
grade: $grade$hintTxt
|
|
101
|
+
|
|
102
|
+
$actions
|
|
103
|
+
|
|
104
|
+
(Disable for this session: HOOKS_ENFORCE=0)
|
|
105
|
+
"@
|
|
106
|
+
|
|
107
|
+
$cid = Get-SafeConversationId $obj
|
|
108
|
+
$pending = Join-Path (Get-HooksPendingDir) "feedback-$cid.txt"
|
|
109
|
+
try {
|
|
110
|
+
New-Item -ItemType Directory -Path (Split-Path $pending) -Force | Out-Null
|
|
111
|
+
$prefix = ''
|
|
112
|
+
if ((Test-Path $pending) -and ((Get-Item $pending).Length -gt 0)) { $prefix = "`n`n---`n`n" }
|
|
113
|
+
Add-Content -Path $pending -Value ($prefix + $msg) -NoNewline
|
|
114
|
+
} catch { }
|
|
115
|
+
|
|
116
|
+
exit 0
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# permission-gate.ps1 - beforeShellExecution for Cursor.
|
|
2
|
+
#
|
|
3
|
+
# Single responsibility: deny a small, explicit list of dangerous commands.
|
|
4
|
+
# This is a *permission* gate, not a *quality* gate. The model handles
|
|
5
|
+
# quality; the harness handles blast radius.
|
|
6
|
+
#
|
|
7
|
+
# Behavior:
|
|
8
|
+
# - Exit 0 always.
|
|
9
|
+
# - Print Cursor-canonical {"permission": "allow"|"deny", ...} JSON.
|
|
10
|
+
# - On internal failure: fail OPEN (allow), never block the user.
|
|
11
|
+
#
|
|
12
|
+
# Disable: PERM_GATE_ENFORCE=0
|
|
13
|
+
|
|
14
|
+
$ErrorActionPreference = 'SilentlyContinue'
|
|
15
|
+
. "$PSScriptRoot\hook-common.ps1"
|
|
16
|
+
|
|
17
|
+
if ($env:PERM_GATE_ENFORCE -eq '0') {
|
|
18
|
+
Write-HookJson @{ permission = 'allow' }
|
|
19
|
+
exit 0
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# Without BOM-safe decode the JSON never parses, the raw-text fallback below
|
|
23
|
+
# matches deny patterns anywhere in the envelope (false positives), and the
|
|
24
|
+
# deny message leaks conversation id / transcript path / user email into the UI.
|
|
25
|
+
$inputText = Read-HookStdin
|
|
26
|
+
|
|
27
|
+
$cmd = ''
|
|
28
|
+
if ($inputText) {
|
|
29
|
+
try {
|
|
30
|
+
$obj = $inputText | ConvertFrom-Json
|
|
31
|
+
if ($obj -and $obj.PSObject.Properties['command']) {
|
|
32
|
+
$cmd = [string]$obj.command
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
$cmd = ''
|
|
36
|
+
}
|
|
37
|
+
# Belt-and-braces: if stdin was not the documented JSON shape, still gate
|
|
38
|
+
# on the raw text rather than waving everything through.
|
|
39
|
+
if (-not $cmd) { $cmd = $inputText }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (-not $cmd) {
|
|
43
|
+
Write-HookJson @{ permission = 'allow' }
|
|
44
|
+
exit 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function Test-Deny {
|
|
48
|
+
param([string]$Pattern, [string]$Reason)
|
|
49
|
+
if ($cmd -match $Pattern) { Deny $Reason }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function Deny {
|
|
53
|
+
param([string]$Reason)
|
|
54
|
+
# Truncate the echo: the command can be a multi-hundred-char one-liner and
|
|
55
|
+
# the UI message only needs enough to identify it.
|
|
56
|
+
$shown = if ($cmd.Length -gt 400) { $cmd.Substring(0, 400) + '...' } else { $cmd }
|
|
57
|
+
$userMsg = "BLOCKED by permission-gate: $Reason`n`nCommand: $shown`n`nIf this is genuinely intended, run it yourself in your terminal."
|
|
58
|
+
Write-HookJson @{
|
|
59
|
+
permission = 'deny'
|
|
60
|
+
user_message = $userMsg
|
|
61
|
+
agent_message = "$userMsg Do not retry verbatim. Ask the user to run it manually if it is truly intended."
|
|
62
|
+
}
|
|
63
|
+
exit 0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# --- POSIX-flavored ---------------------------------------------------------
|
|
67
|
+
# Anchored to start OR a command separator so `cd /tmp && rm -rf /` is caught,
|
|
68
|
+
# while `git rm`, `npm run rm-cache`, `echo "rm -rf /"` stay allowed.
|
|
69
|
+
Test-Deny '(?:^|[;&|]\s*)(?:sudo\s+)?rm\s+-[a-zA-Z]*([rR][fF]|[fF][rR])[a-zA-Z]*\s+/' 'destructive rm -rf on absolute path (use relative paths or be more specific)'
|
|
70
|
+
Test-Deny ':\(\)\{\s*:\|:&\s*\};:|bash\s+-c\s+["'']*:\s*\(\)\{' 'reverse shell / fork-bomb pattern'
|
|
71
|
+
Test-Deny 'curl\s.*\|\s*(sudo\s*)?(bash|sh|zsh|dash|ash)' 'curl piped to shell'
|
|
72
|
+
Test-Deny 'wget\s.*\|\s*(sudo\s*)?(bash|sh|zsh|dash|ash)' 'wget piped to shell'
|
|
73
|
+
Test-Deny 'git\s+push\s+.*--force(-with-lease)?(\s|$)' 'git push --force'
|
|
74
|
+
Test-Deny 'git\s+push\s+(-f|--force)(\s|$)' 'git push -f / --force'
|
|
75
|
+
Test-Deny 'git\s+reset\s+--hard' 'git reset --hard (data loss)'
|
|
76
|
+
Test-Deny 'git\s+clean\s+-[a-zA-Z]*f' 'git clean -f (untracked data loss)'
|
|
77
|
+
Test-Deny 'dd\s.*of=/dev/(sd|nvme|hd|xvd)' 'dd to block device'
|
|
78
|
+
Test-Deny 'mkfs(\.[a-z0-9]+)?\s+/dev/' 'mkfs on device'
|
|
79
|
+
Test-Deny 'chmod\s+-R\s+777\s+/' 'chmod -R 777 on root'
|
|
80
|
+
Test-Deny 'chown\s+-R\s+[^\s]+\s+/' 'chown -R on root'
|
|
81
|
+
Test-Deny '^(npm|pnpm|yarn)\s+publish(\s|$)' 'package publish (use ship-hook, not direct publish)'
|
|
82
|
+
|
|
83
|
+
# --- Windows equivalents (the agent shell here IS PowerShell) ---------------
|
|
84
|
+
# iwr/irm | iex is the moral twin of curl|sh.
|
|
85
|
+
Test-Deny '\b(iwr|irm|curl|wget|Invoke-WebRequest|Invoke-RestMethod)\b[^|]*\|\s*(iex\b|Invoke-Expression)' 'web download piped to Invoke-Expression'
|
|
86
|
+
# Disk-level destruction, twin of mkfs / dd-to-device.
|
|
87
|
+
Test-Deny '\b(Format-Volume|Clear-Disk)\b' 'disk format / clear (destructive)'
|
|
88
|
+
# Recursive+forced delete of a bare drive root, user-profile root, or
|
|
89
|
+
# C:\Users / C:\Windows. Twin of rm -rf /. Composed checks instead of one
|
|
90
|
+
# unreadable regex; subfolder deletes (e.g. C:\Temp\x) stay allowed.
|
|
91
|
+
$rmVerb = '(?:^|[;&|]\s*)(?:Remove-Item|rm|ri|del|erase|rd|rmdir)\s'
|
|
92
|
+
$rootPath = '(?:^|[\s"''])(?:[A-Za-z]:[\\/]{0,2}|[A-Za-z]:[\\/](?:Users|Windows)[\\/]?|\$(?:env:USERPROFILE|HOME)[\\/]?)["'']?\s*(?:$|[;&|-])'
|
|
93
|
+
if (($cmd -match $rmVerb) -and ($cmd -match $rootPath) -and ($cmd -match '(?:-Recurse\b|/s\b)') -and ($cmd -match '(?:-Force\b|/q\b)')) {
|
|
94
|
+
Deny 'recursive forced delete of a drive root / Users / Windows / profile root'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
Write-HookJson @{ permission = 'allow' }
|
|
98
|
+
exit 0
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# post-tool-use.ps1 - postToolUse for Cursor.
|
|
2
|
+
#
|
|
3
|
+
# Two responsibilities, both message-bus work, keyed by conversation_id so
|
|
4
|
+
# concurrent sessions never receive each other's prompts:
|
|
5
|
+
#
|
|
6
|
+
# 1. Fold completed subagents' session-edits markers into this
|
|
7
|
+
# conversation's marker (postToolUse does NOT fire for the Task tool -
|
|
8
|
+
# verified - so this per-tool-boundary fold is how delegated edits reach
|
|
9
|
+
# the parent's stop-hook final review). When a fold happens, prime the
|
|
10
|
+
# parent to audit the subagent's diff now.
|
|
11
|
+
# 2. Drain this conversation's stashed self-review / advisory messages into
|
|
12
|
+
# Cursor's additional_context channel. One-shot delivery.
|
|
13
|
+
#
|
|
14
|
+
# We do not parse, score, or filter. We do not run any audit. We do not
|
|
15
|
+
# block. The model that already produced the edit will, on its next
|
|
16
|
+
# turn, do the self-review.
|
|
17
|
+
|
|
18
|
+
$ErrorActionPreference = 'SilentlyContinue'
|
|
19
|
+
. "$PSScriptRoot\hook-common.ps1"
|
|
20
|
+
|
|
21
|
+
$obj = Read-HookStdinJson
|
|
22
|
+
$cid = Get-SafeConversationId $obj
|
|
23
|
+
|
|
24
|
+
$foldNote = ''
|
|
25
|
+
if (Merge-SubagentEditMarkers $obj $cid) {
|
|
26
|
+
$foldNote = "SUBAGENT WORK DETECTED - a subagent of this conversation edited files (its edits fired hooks in ITS context, not yours). YOU are the auditor of its work: audit its diff (git status / git diff on the files it touched) against ~/.agents/hooks/self-review.md. Fix real bugs; stay silent otherwise. Its files are folded into this conversation's end-of-implementation review."
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
$pendingFile = Join-Path (Get-HooksPendingDir) "feedback-$cid.txt"
|
|
30
|
+
|
|
31
|
+
$msg = ''
|
|
32
|
+
if (Test-Path $pendingFile) {
|
|
33
|
+
if ((Get-Item $pendingFile).Length -gt 0) {
|
|
34
|
+
$msg = Get-Content $pendingFile -Raw
|
|
35
|
+
}
|
|
36
|
+
# One-shot: clear before emitting so a hook error doesn't replay forever.
|
|
37
|
+
Remove-Item $pendingFile -Force -ErrorAction SilentlyContinue
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if ($foldNote) {
|
|
41
|
+
if ($msg) { $msg = "$foldNote`n`n---`n`n$msg" } else { $msg = $foldNote }
|
|
42
|
+
}
|
|
43
|
+
if (-not $msg) { exit 0 }
|
|
44
|
+
|
|
45
|
+
Write-HookJson @{ additional_context = $msg }
|
|
46
|
+
exit 0
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# self-review-trigger.ps1 - afterFileEdit for Cursor.
|
|
2
|
+
#
|
|
3
|
+
# Single responsibility: when the model just edited a file, hand the
|
|
4
|
+
# edit context to the NEXT model turn as additional_context. The model
|
|
5
|
+
# is the auditor; the harness is just the message bus.
|
|
6
|
+
#
|
|
7
|
+
# This is intentionally minimal:
|
|
8
|
+
# - We do NOT parse the diff ourselves.
|
|
9
|
+
# - We do NOT spawn a sub-agent.
|
|
10
|
+
# - We do NOT write to .stuck-files/.
|
|
11
|
+
# - We do NOT block.
|
|
12
|
+
#
|
|
13
|
+
# We DO:
|
|
14
|
+
# - Capture the edited file path.
|
|
15
|
+
# - Stash a self-review prompt that primes the model's next turn.
|
|
16
|
+
# - Exit 0 always.
|
|
17
|
+
#
|
|
18
|
+
# Cursor's afterFileEdit doesn't consume its own output. To actually
|
|
19
|
+
# surface the message, post-tool-use.ps1 re-emits it on the next tool
|
|
20
|
+
# boundary. See hooks.json.
|
|
21
|
+
|
|
22
|
+
$ErrorActionPreference = 'SilentlyContinue'
|
|
23
|
+
. "$PSScriptRoot\hook-common.ps1"
|
|
24
|
+
|
|
25
|
+
$inputText = Read-HookStdin
|
|
26
|
+
|
|
27
|
+
$filePath = ''
|
|
28
|
+
$cid = ''
|
|
29
|
+
if ($inputText) {
|
|
30
|
+
try {
|
|
31
|
+
$obj = $inputText | ConvertFrom-Json
|
|
32
|
+
if ($obj) {
|
|
33
|
+
if ($obj.PSObject.Properties['file_path']) { $filePath = [string]$obj.file_path }
|
|
34
|
+
elseif ($obj.PSObject.Properties['path']) { $filePath = [string]$obj.path }
|
|
35
|
+
elseif ($obj.PSObject.Properties['filePath']) { $filePath = [string]$obj.filePath }
|
|
36
|
+
$cid = Get-SafeConversationId $obj
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
$filePath = ''
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Empty path (JSON parse failed, or no file_path field) -> nothing to record.
|
|
44
|
+
# Without this guard the .cursor regex below doesn't match '' and we append a
|
|
45
|
+
# blank line to the session-edits marker on every such fire (it accumulates fast).
|
|
46
|
+
if (-not $filePath) { exit 0 }
|
|
47
|
+
if (Test-IsCursorConfigPath $filePath) { exit 0 }
|
|
48
|
+
|
|
49
|
+
# State is keyed by conversation_id and lives under $HOME, never the project:
|
|
50
|
+
# no repo litter, works in workspace-less sessions (CURSOR_PROJECT_DIR/
|
|
51
|
+
# workspace_roots are empty there), and concurrent sessions cannot drain each
|
|
52
|
+
# other's prompts.
|
|
53
|
+
$pendingDir = Get-HooksPendingDir
|
|
54
|
+
|
|
55
|
+
# Record this edit for the end-of-implementation review. The stop hook
|
|
56
|
+
# (final-review.ps1) drains this marker to fire one final review pass over
|
|
57
|
+
# everything changed this agent loop. Append = running list of edits.
|
|
58
|
+
try {
|
|
59
|
+
$mk = Join-Path $pendingDir "session-edits-$cid.txt"
|
|
60
|
+
New-Item -ItemType Directory -Path $pendingDir -Force | Out-Null
|
|
61
|
+
Add-Content -Path $mk -Value $filePath
|
|
62
|
+
} catch { }
|
|
63
|
+
|
|
64
|
+
$doctrineFile = Join-Path $HOME '.agents\hooks\self-review.md'
|
|
65
|
+
if (-not (Test-Path $doctrineFile)) { exit 0 }
|
|
66
|
+
$doctrine = Get-Content $doctrineFile -Raw
|
|
67
|
+
|
|
68
|
+
$msg = "SELF-REVIEW TRIGGER - you just edited: $filePath`n`n$doctrine"
|
|
69
|
+
|
|
70
|
+
$pendingFile = Join-Path $pendingDir "feedback-$cid.txt"
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
New-Item -ItemType Directory -Path $pendingDir -Force | Out-Null
|
|
74
|
+
$prefix = ''
|
|
75
|
+
if ((Test-Path $pendingFile) -and ((Get-Item $pendingFile).Length -gt 0)) {
|
|
76
|
+
$prefix = "`n`n---`n`n"
|
|
77
|
+
}
|
|
78
|
+
Add-Content -Path $pendingFile -Value ($prefix + $msg) -NoNewline
|
|
79
|
+
} catch {
|
|
80
|
+
# Silently fail open
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
exit 0
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
You are the auditor of your own edit. The user's `Edit` tool just changed a
|
|
2
|
+
file. Your job, on this turn, is to:
|
|
3
|
+
|
|
4
|
+
1. Read the file that was just changed.
|
|
5
|
+
2. Read the diff (provided in the prior tool output).
|
|
6
|
+
3. Decide: does this edit introduce any of the following?
|
|
7
|
+
- **Security**: hardcoded secret (AWS key, private key, API token,
|
|
8
|
+
password in source), `eval(`, `exec(`, `pickle.loads`, `verify=False`,
|
|
9
|
+
`child_process` with user input, `dangerouslySetInnerHTML` with
|
|
10
|
+
untrusted data, SQL string concat.
|
|
11
|
+
- **Correctness**: assignment-in-condition (`if (x = 5)`), `==`/`!=`
|
|
12
|
+
with `null`/`None`/`NaN` in a comparison, `forEach` with `await`,
|
|
13
|
+
async `useEffect` with side-effects missing cleanup, `==` instead
|
|
14
|
+
of `===` in JS, mutable default args in Python, shadowed imports,
|
|
15
|
+
dead relative imports.
|
|
16
|
+
- **Safety**: `rm -rf /`, `curl ... | sh`, force-push, `git reset --hard`
|
|
17
|
+
without a backup, `npm publish` without version bump, secret
|
|
18
|
+
committed to a public file.
|
|
19
|
+
- **Logic bugs that the user would actually care about**: a function
|
|
20
|
+
that returns the wrong thing, an off-by-one, a missing `return`, a
|
|
21
|
+
wrong import path.
|
|
22
|
+
4. If you find a real bug, **fix it with `Edit`**, then say nothing.
|
|
23
|
+
Do not report it. Do not explain it. The user will see the fix
|
|
24
|
+
in the next message; the bug is gone.
|
|
25
|
+
5. If the edit is clean, respond with the single word: `clean`.
|
|
26
|
+
|
|
27
|
+
Hard constraints:
|
|
28
|
+
|
|
29
|
+
- **Never revert or re-do work the user asked for.** The user's intent
|
|
30
|
+
is the source of truth. You are a *post-hoc* auditor, not a rewriter.
|
|
31
|
+
- **Never change style, naming, formatting, or "improvements" the user
|
|
32
|
+
did not ask for.** If the user added a one-liner with bad formatting,
|
|
33
|
+
leave it. Self-review is for *bugs*, not taste.
|
|
34
|
+
- **Never re-read the whole repo.** Only the file you just edited, and
|
|
35
|
+
the diff. Context is finite.
|
|
36
|
+
- **Never run shell commands in this turn.** Your only allowed tool is
|
|
37
|
+
`Read` and `Edit`. (And `Edit` only if you are fixing a real bug.)
|
|
38
|
+
- **If you are uncertain whether something is a bug, leave it.** False
|
|
39
|
+
positives waste user time. The bar is "this would fail a careful
|
|
40
|
+
code review at Anthropic / Stripe / Vercel." Cosmetic things, missing
|
|
41
|
+
type hints, and "you could write this more idiomatically" are NOT bugs.
|
|
42
|
+
- **One pass, no recursion.** If you fix one bug and find another, fix
|
|
43
|
+
that too — but stop after at most 2 edits. Beyond that you are
|
|
44
|
+
thrashing.
|
|
45
|
+
|
|
46
|
+
This is the entire self-review prompt. It is the same prompt, every
|
|
47
|
+
edit, forever. The model is the auditor. There is no regex, no AST
|
|
48
|
+
parse, no Python — the model itself does the work.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# subagent-stop-review.ps1 - subagentStop for Cursor.
|
|
2
|
+
#
|
|
3
|
+
# Counterpart of final-review.ps1 for delegated work. afterFileEdit DOES fire
|
|
4
|
+
# inside subagents (verified: a poteto subagent run left ~58 entries in
|
|
5
|
+
# session-edits-<subagent-cid>.txt), but subagents get no `stop` event, so
|
|
6
|
+
# that marker is never drained and the four-axis review never fires for
|
|
7
|
+
# delegated implementations. This hook closes the loop: when a subagent
|
|
8
|
+
# finishes and ITS conversation has a session-edits marker, return ONE
|
|
9
|
+
# followup_message so the subagent audits its own implementation before the
|
|
10
|
+
# result goes back to the parent.
|
|
11
|
+
#
|
|
12
|
+
# Same bounding pattern as final-review.ps1:
|
|
13
|
+
# - marker-gated: no edits in the subagent run -> no review, no noise,
|
|
14
|
+
# - reviewed-<cid>.flag one-shot brake: the stop AFTER the review pass
|
|
15
|
+
# clears flag + marker and ends the loop (one review per implementation;
|
|
16
|
+
# resumed subagents with a second implementation get a second review),
|
|
17
|
+
# - loop_limit in hooks.json caps runaway follow-ups harness-side,
|
|
18
|
+
# - only on status == 'completed' when a status field is present.
|
|
19
|
+
#
|
|
20
|
+
# If subagentStop's stdin carries a conversation_id that doesn't match the
|
|
21
|
+
# id afterFileEdit used, the marker lookup misses and this emits {} - the
|
|
22
|
+
# marker fold in post-tool-use.ps1 / final-review.ps1 still routes the
|
|
23
|
+
# subagent's edits into the parent's stop review as the backstop.
|
|
24
|
+
#
|
|
25
|
+
# Always emits valid JSON ({} = no follow-up). Review body reuses
|
|
26
|
+
# final-review.md (embedded fallback if missing).
|
|
27
|
+
# Disable: HOOKS_ENFORCE=0 or SUBAGENT_REVIEW_ENFORCE=0.
|
|
28
|
+
|
|
29
|
+
$ErrorActionPreference = 'SilentlyContinue'
|
|
30
|
+
. "$PSScriptRoot\hook-common.ps1"
|
|
31
|
+
|
|
32
|
+
function Emit-None { '{}'; exit 0 }
|
|
33
|
+
|
|
34
|
+
if ($env:HOOKS_ENFORCE -eq '0' -or $env:SUBAGENT_REVIEW_ENFORCE -eq '0') { Emit-None }
|
|
35
|
+
|
|
36
|
+
$obj = Read-HookStdinJson
|
|
37
|
+
if (-not $obj) { Emit-None }
|
|
38
|
+
|
|
39
|
+
$status = ''
|
|
40
|
+
if ($obj.PSObject.Properties['status']) { $status = [string]$obj.status }
|
|
41
|
+
$cid = Get-SafeConversationId $obj
|
|
42
|
+
|
|
43
|
+
$pendingDir = Get-HooksPendingDir
|
|
44
|
+
$marker = Join-Path $pendingDir "session-edits-$cid.txt"
|
|
45
|
+
$flag = Join-Path $pendingDir "reviewed-$cid.flag"
|
|
46
|
+
|
|
47
|
+
# One-shot brake: the previous subagentStop for this id emitted the review.
|
|
48
|
+
if (Test-Path $flag) {
|
|
49
|
+
Remove-Item $flag, $marker -Force -ErrorAction SilentlyContinue
|
|
50
|
+
Emit-None
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Review only a clean completion; otherwise clear the marker and stop.
|
|
54
|
+
if ($status -and $status -ne 'completed') {
|
|
55
|
+
Remove-Item $marker -Force -ErrorAction SilentlyContinue
|
|
56
|
+
Emit-None
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# No edits this run -> nothing to review.
|
|
60
|
+
if (-not (Test-Path $marker)) { Emit-None }
|
|
61
|
+
$edited = @(Get-Content $marker -ErrorAction SilentlyContinue |
|
|
62
|
+
Where-Object { $_ -and $_.Trim() } | Select-Object -Unique)
|
|
63
|
+
Remove-Item $marker -Force -ErrorAction SilentlyContinue
|
|
64
|
+
if ($edited.Count -eq 0) { Emit-None }
|
|
65
|
+
|
|
66
|
+
$body = ''
|
|
67
|
+
$promptFile = Join-Path $HOME '.agents\hooks\final-review.md'
|
|
68
|
+
if (Test-Path $promptFile) { $body = Get-Content -Raw $promptFile }
|
|
69
|
+
if (-not $body) {
|
|
70
|
+
$body = @'
|
|
71
|
+
Audit everything you changed in this run and FIX what fails (do NOT revert the
|
|
72
|
+
behaviour the task asked for):
|
|
73
|
+
1. Correctness - logic, edge cases (null/empty/zero/boundary), language traps, security.
|
|
74
|
+
2. Reliability - error paths handled, no swallowed errors, resources released.
|
|
75
|
+
3. Coverage - behaviour-bearing changes have real tests; RUN the suite if present.
|
|
76
|
+
4. Anti-slop - no duplicate helpers, premature abstraction, unneeded deps,
|
|
77
|
+
redundant comments, dead code.
|
|
78
|
+
If an axis is clean, say so in one line. Then stop.
|
|
79
|
+
'@
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
$fileList = ($edited | Select-Object -First 30) -join "`n "
|
|
83
|
+
$msg = "SUBAGENT FINAL REVIEW - you just finished delegated implementation work. Before your result returns to the parent agent, audit it.`n`nFiles you changed this run:`n $fileList`n`n$body"
|
|
84
|
+
|
|
85
|
+
# Arm the one-shot brake BEFORE emitting, so a crash after emit can't re-fire.
|
|
86
|
+
New-Item -ItemType File -Path $flag -Force -ErrorAction SilentlyContinue | Out-Null
|
|
87
|
+
|
|
88
|
+
Write-HookJson @{ followup_message = $msg }
|
|
89
|
+
exit 0
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"hooks": {
|
|
4
|
+
"sessionStart": [
|
|
5
|
+
{
|
|
6
|
+
"command": "pwsh.exe -NoProfile -File ~/.cursor/inject-doctrine.ps1",
|
|
7
|
+
"timeout": 5,
|
|
8
|
+
"_comment": "5s: inject the agent doctrine + user rules at session start. inject-doctrine.ps1 reads ~/.cursor/doctrine.md + USER-RULES.md and emits them as {\"additional_context\": ...} JSON (sessionStart does NOT consume raw stdout)."
|
|
9
|
+
}
|
|
10
|
+
],
|
|
11
|
+
"afterFileEdit": [
|
|
12
|
+
{
|
|
13
|
+
"command": "pwsh.exe -NoProfile -File ~/.agents/hooks/self-review-trigger.ps1",
|
|
14
|
+
"timeout": 5,
|
|
15
|
+
"matcher": "^(Write|StrReplace|EditNotebook)$",
|
|
16
|
+
"_comment": "5s: record the edit in ~/.cursor/.hooks-pending/session-edits-<conversation_id>.txt and stash the self-review prompt in feedback-<conversation_id>.txt. The harness normalizes agent file edits (incl. StrReplace) to tool type 'Write' in this event - verified via payload capture - so ^Write$ matches them all; the alternation is defensive against future harness versions reporting raw tool names. Anchored so TabWrite (every user tab-completion) stays excluded. The model is the auditor. NOTE: fires inside subagent contexts too, keyed by the SUBAGENT's conversation_id - see subagentStop + the marker fold in post-tool-use.ps1/final-review.ps1."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"command": "pwsh.exe -NoProfile -File ~/.agents/hooks/minimal-edit-audit.ps1",
|
|
20
|
+
"timeout": 15,
|
|
21
|
+
"matcher": "^(Write|StrReplace|EditNotebook)$",
|
|
22
|
+
"_comment": "15s: minimal-editing advisory on the edited file (native git --numstat line-count + audit-metrics.py token metrics on .py, from ~/.cursor/skills/minimal-editing/). Appends findings to the conversation's pending file; never blocks. Disable: HOOKS_ENFORCE=0 or MINIMAL_EDITING_ENFORCE=0."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"command": "pwsh.exe -NoProfile -File ~/.agents/hooks/anti-slop-audit.ps1",
|
|
26
|
+
"timeout": 15,
|
|
27
|
+
"matcher": "^(Write|StrReplace|EditNotebook)$",
|
|
28
|
+
"_comment": "15s: AI-slop advisory, companion to minimal-edit-audit. Native git diff flags new deps / premature abstractions (Factory/Repository/Mediator/CQRS/DDD) / redundant comments, and injects the anti-slop.md self-review checklist on substantial edits (>= ANTI_SLOP_CHECKLIST_LINES, default 40). Appends to the conversation's pending file; never blocks. Disable: HOOKS_ENFORCE=0 or ANTI_SLOP_ENFORCE=0."
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"postToolUse": [
|
|
32
|
+
{
|
|
33
|
+
"command": "pwsh.exe -NoProfile -File ~/.agents/hooks/post-tool-use.ps1",
|
|
34
|
+
"timeout": 5,
|
|
35
|
+
"_comment": "5s: (1) fold completed subagents' session-edits markers into this conversation's marker - subagent edits fire afterFileEdit under the SUBAGENT's conversation_id, and postToolUse does NOT fire for the Task tool (verified by payload logging), so per-tool-boundary folding is how delegated edits reach the parent's stop-hook review - and prime the parent to audit the subagent diff; (2) drain this conversation's pending-feedback file (self-review + advisories) into additional_context. One-shot. No matcher: fires after EVERY tool so pending is delivered promptly."
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"subagentStop": [
|
|
39
|
+
{
|
|
40
|
+
"command": "pwsh.exe -NoProfile -File ~/.agents/hooks/subagent-stop-review.ps1",
|
|
41
|
+
"timeout": 5,
|
|
42
|
+
"matcher": "^(generalPurpose|poteto-agent|best-of-n-runner|impeccable-manual-edit-applier)$",
|
|
43
|
+
"loop_limit": 3,
|
|
44
|
+
"_comment": "5s: ONE in-subagent final review per implementation before the result returns to the parent. Matcher = editing-capable subagent types only. Marker-gated like final-review.ps1: afterFileEdit fires inside subagents keyed by the subagent's conversation_id, so session-edits-<subagent-cid>.txt exists exactly when the run edited files; reviewed-<cid>.flag is the per-implementation brake, loop_limit 3 is the harness-side runaway cap (1 would suppress the review after a resumed subagent's second implementation). Disable: HOOKS_ENFORCE=0 or SUBAGENT_REVIEW_ENFORCE=0."
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"beforeShellExecution": [
|
|
48
|
+
{
|
|
49
|
+
"command": "pwsh.exe -NoProfile -File ~/.agents/hooks/permission-gate.ps1",
|
|
50
|
+
"timeout": 5,
|
|
51
|
+
"failClosed": false,
|
|
52
|
+
"_comment": "5s: deny a small explicit list of dangerous commands. Default-allow, deny-by-list."
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"stop": [
|
|
56
|
+
{
|
|
57
|
+
"command": "pwsh.exe -NoProfile -File ~/.agents/hooks/final-review.ps1",
|
|
58
|
+
"timeout": 5,
|
|
59
|
+
"loop_limit": 5,
|
|
60
|
+
"_comment": "5s: ONE comprehensive end-of-implementation review across correctness + reliability + coverage + anti-slop. If the agent edited files this loop (session-edits-<conversation_id> marker), returns {followup_message} so Cursor auto-submits ONE review pass. Bounded by the script's per-conversation reviewed-flag (one review per implementation); loop_limit counts follow-ups per CONVERSATION (docs), so 5 = harness-side runaway cap, not the per-implementation bound (2 would silently kill reviews after the 2nd implementation in a long chat). Disable: HOOKS_ENFORCE=0 or FINAL_REVIEW_ENFORCE=0."
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# inject-doctrine.ps1 - Cursor sessionStart injection.
|
|
2
|
+
#
|
|
3
|
+
# Emits {"additional_context": "<doctrine + USER-RULES>"} as PURE-ASCII JSON.
|
|
4
|
+
#
|
|
5
|
+
# Why pure ASCII: the doctrine contains multi-byte UTF-8 characters (em dash,
|
|
6
|
+
# section sign, <=, arrows). Written as UTF-8, their continuation bytes
|
|
7
|
+
# (0x80-0x9F) get decoded by Cursor's JSON reader as C1 control characters ->
|
|
8
|
+
# "Bad control character in string literal in JSON at position N". Escaping every
|
|
9
|
+
# non-ASCII char to \uXXXX makes the output byte-identical under EVERY encoding,
|
|
10
|
+
# so it cannot be mangled; JSON.parse turns § back into the real char. We
|
|
11
|
+
# also write the bytes straight to stdout to bypass [Console]::OutputEncoding.
|
|
12
|
+
#
|
|
13
|
+
# Fail open: missing files or any error -> "{}" (valid, empty). Never block or
|
|
14
|
+
# crash session start.
|
|
15
|
+
|
|
16
|
+
$ErrorActionPreference = 'SilentlyContinue'
|
|
17
|
+
|
|
18
|
+
# Drain stdin (Cursor sends session metadata) so the pipe never blocks.
|
|
19
|
+
$null = [Console]::In.ReadToEnd()
|
|
20
|
+
|
|
21
|
+
function Write-StdoutAscii([string]$s) {
|
|
22
|
+
# Write exact ASCII bytes to stdout, immune to whatever [Console]::OutputEncoding is.
|
|
23
|
+
$bytes = [System.Text.Encoding]::ASCII.GetBytes($s)
|
|
24
|
+
$stdout = [Console]::OpenStandardOutput()
|
|
25
|
+
$stdout.Write($bytes, 0, $bytes.Length)
|
|
26
|
+
$stdout.Flush()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
$paths = @(
|
|
31
|
+
(Join-Path $PSScriptRoot 'doctrine.md'),
|
|
32
|
+
(Join-Path $PSScriptRoot 'USER-RULES.md')
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
$parts = foreach ($p in $paths) {
|
|
36
|
+
if (Test-Path -LiteralPath $p) { Get-Content -Raw -LiteralPath $p }
|
|
37
|
+
}
|
|
38
|
+
$context = ($parts -join "`n`n").Trim()
|
|
39
|
+
|
|
40
|
+
if (-not $context) { Write-StdoutAscii '{}'; exit 0 }
|
|
41
|
+
|
|
42
|
+
$json = @{ additional_context = $context } | ConvertTo-Json -Compress
|
|
43
|
+
|
|
44
|
+
# Escape every non-ASCII (and any stray control) char to \uXXXX -> pure ASCII.
|
|
45
|
+
# ConvertTo-Json's structural chars and \n / \" escapes are ASCII and pass through.
|
|
46
|
+
$sb = [System.Text.StringBuilder]::new($json.Length + 64)
|
|
47
|
+
foreach ($ch in $json.ToCharArray()) {
|
|
48
|
+
$code = [int][char]$ch
|
|
49
|
+
if ($code -lt 32 -or $code -gt 126) { [void]$sb.AppendFormat('\u{0:x4}', $code) }
|
|
50
|
+
else { [void]$sb.Append($ch) }
|
|
51
|
+
}
|
|
52
|
+
Write-StdoutAscii $sb.ToString()
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
Write-StdoutAscii '{}'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
exit 0
|