cursordoctrine 0.3.2 → 0.3.3
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 +1 -1
- package/README.md +1 -0
- package/bin/cli.mjs +1 -0
- package/linux/hooks/final-review.md +11 -0
- package/linux/hooks/scope-gate-audit.sh +139 -0
- package/linux/hooks.json +6 -0
- package/package.json +1 -1
- package/skills/anti-slop/SKILL.md +7 -3
- package/skills/anti-slop/scripts/scope_match.py +139 -0
- package/windows/hooks/final-review.md +11 -0
- package/windows/hooks/scope-gate-audit.ps1 +125 -0
- package/windows/hooks.json +6 -0
package/INSTALL.md
CHANGED
|
@@ -110,4 +110,4 @@ Also validate the config: `~/.cursor/hooks.json` must parse as JSON.
|
|
|
110
110
|
|
|
111
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
112
|
|
|
113
|
-
Kill switches if something misbehaves: `HOOKS_ENFORCE=0` (everything advisory off), `PERM_GATE_ENFORCE=0`, `MINIMAL_EDITING_ENFORCE=0` (deprecated in 0.3.0), `SEMANTIC_DENSITY_ENFORCE=0`, `ANTI_SLOP_ENFORCE=0`, `FINAL_REVIEW_ENFORCE=0`, `SUBAGENT_REVIEW_ENFORCE=0`.
|
|
113
|
+
Kill switches if something misbehaves: `HOOKS_ENFORCE=0` (everything advisory off), `PERM_GATE_ENFORCE=0`, `MINIMAL_EDITING_ENFORCE=0` (deprecated in 0.3.0), `SEMANTIC_DENSITY_ENFORCE=0`, `SCOPE_GATE_ENFORCE=0`, `ANTI_SLOP_ENFORCE=0`, `FINAL_REVIEW_ENFORCE=0`, `SUBAGENT_REVIEW_ENFORCE=0`.
|
package/README.md
CHANGED
|
@@ -86,6 +86,7 @@ All hooks fail open and always exit 0. Nothing here can block your session.
|
|
|
86
86
|
| `HOOKS_ENFORCE=0` | on | turns off all advisory hooks at once |
|
|
87
87
|
| `PERM_GATE_ENFORCE=0` | on | disables the permission gate |
|
|
88
88
|
| `MINIMAL_EDITING_ENFORCE=0` | on | disables the over-edit advisory (deprecated in 0.3.0) |
|
|
89
|
+
| `SCOPE_GATE_ENFORCE=0` | on | disables the declared-scope advisory (opt-in: only fires when `.scope.json` exists) |
|
|
89
90
|
| `SEMANTIC_DENSITY_ENFORCE=0` | on | disables the semantic-opacity advisory |
|
|
90
91
|
| `ANTI_SLOP_ENFORCE=0` | on | disables the slop advisory |
|
|
91
92
|
| `FINAL_REVIEW_ENFORCE=0` | on | disables the final review pass |
|
package/bin/cli.mjs
CHANGED
|
@@ -374,6 +374,7 @@ Kill switches (environment variables, all hooks fail open)
|
|
|
374
374
|
PERM_GATE_ENFORCE=0 permission gate off
|
|
375
375
|
MINIMAL_EDITING_ENFORCE=0 over-edit advisory off (deprecated in 0.3.0)
|
|
376
376
|
SEMANTIC_DENSITY_ENFORCE=0 semantic-opacity advisory off
|
|
377
|
+
SCOPE_GATE_ENFORCE=0 declared-scope advisory off
|
|
377
378
|
ANTI_SLOP_ENFORCE=0 slop advisory off
|
|
378
379
|
FINAL_REVIEW_ENFORCE=0 final review off
|
|
379
380
|
SUBAGENT_REVIEW_ENFORCE=0 in-subagent review off
|
|
@@ -64,6 +64,17 @@ Step C — session footprint (also in the header above):
|
|
|
64
64
|
If "Session footprint" shows >5 files or the request was simple, justify each
|
|
65
65
|
file or trim. Unjustified files are slop.
|
|
66
66
|
|
|
67
|
+
Step D — declared scope (closing gate for Compuerta 1):
|
|
68
|
+
If `.scope.json` exists in the repo root, run the session's full diff against
|
|
69
|
+
the declared contract. In your shell:
|
|
70
|
+
for f in $(git diff --name-only HEAD); do
|
|
71
|
+
python ~/.cursor/skills/anti-slop/scripts/scope_match.py --path "$f" --patterns-file .scope.json
|
|
72
|
+
done
|
|
73
|
+
Any file reporting `"in_scope": false` is a scope violation you must justify
|
|
74
|
+
(add to .scope.json with a one-line reason) or revert. If `.scope.json` does
|
|
75
|
+
not exist, this step is skipped — the declared-editing ladder and the
|
|
76
|
+
per-edit scope-gate-audit hook are the opt-in discipline.
|
|
77
|
+
|
|
67
78
|
Fix with edits now; re-run the scan (if Step A ran) and the tests; then stop.
|
|
68
79
|
|
|
69
80
|
## 5. Wiring completeness
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# scope-gate-audit.sh - afterFileEdit "declared scope" advisory (Cursor, Linux).
|
|
3
|
+
#
|
|
4
|
+
# Compuerta 1 of the anti-slop system: the declared-scope gate. When the agent
|
|
5
|
+
# writes a .scope.json contract (intent + files[] + acceptance), this hook
|
|
6
|
+
# checks every edited file against it. Editing OUTSIDE the declared set is the
|
|
7
|
+
# textbook scope-creep / gold-plating signal. Advisory only (no preToolUse for
|
|
8
|
+
# file edits on Cursor); the violation is flagged on the next turn.
|
|
9
|
+
#
|
|
10
|
+
# Opt-in: if .scope.json does not exist in the repo root, this hook is silent.
|
|
11
|
+
# No contract = no gate (fallback to declared-editing ladder + final-review).
|
|
12
|
+
#
|
|
13
|
+
# Mechanism: resolve edited file -> repo-relative, run scope_match.py against
|
|
14
|
+
# .scope.json's files[], append advisory to feedback-<cid>.txt on violation.
|
|
15
|
+
#
|
|
16
|
+
# Advisory only: never blocks, never persists state, ALWAYS exits 0.
|
|
17
|
+
# Disable: HOOKS_ENFORCE=0 or SCOPE_GATE_ENFORCE=0
|
|
18
|
+
|
|
19
|
+
set +e
|
|
20
|
+
. "$(dirname "$0")/hook-common.sh"
|
|
21
|
+
|
|
22
|
+
[ "${HOOKS_ENFORCE:-}" = "0" ] && exit 0
|
|
23
|
+
[ "${SCOPE_GATE_ENFORCE:-}" = "0" ] && exit 0
|
|
24
|
+
|
|
25
|
+
input="$(read_hook_stdin)"
|
|
26
|
+
[ -n "$input" ] || exit 0
|
|
27
|
+
|
|
28
|
+
# audit root: project from JSON (cwd, then workspace_roots), else CURSOR_PROJECT_DIR / HOME
|
|
29
|
+
root=""
|
|
30
|
+
while IFS= read -r cand; do
|
|
31
|
+
[ -n "$cand" ] && [ -d "$cand" ] && { root="${cand%/}"; break; }
|
|
32
|
+
done <<EOF
|
|
33
|
+
$(json_get "$input" cwd)
|
|
34
|
+
$(json_get_array "$input" workspace_roots)
|
|
35
|
+
EOF
|
|
36
|
+
[ -n "$root" ] || root="${CURSOR_PROJECT_DIR:-$HOME}"
|
|
37
|
+
root="${root%/}"
|
|
38
|
+
|
|
39
|
+
# edited file -> repo-relative path
|
|
40
|
+
fp=""
|
|
41
|
+
for k in file_path path filename absolute_path abs_path; do
|
|
42
|
+
fp="$(json_get "$input" "$k")"
|
|
43
|
+
[ -n "$fp" ] && break
|
|
44
|
+
done
|
|
45
|
+
[ -n "$fp" ] || exit 0
|
|
46
|
+
rel="$fp"
|
|
47
|
+
case "$rel" in "$root"/*) rel="${rel#"$root"/}" ;; esac
|
|
48
|
+
if is_cursor_config_path "$fp" || is_cursor_config_path "$rel"; then exit 0; fi
|
|
49
|
+
|
|
50
|
+
# --- opt-in gate: no .scope.json = no gate ---------------------------------
|
|
51
|
+
scope_file="$root/.scope.json"
|
|
52
|
+
[ -f "$scope_file" ] || exit 0
|
|
53
|
+
|
|
54
|
+
# --- resolve Python + run scope_match.py ---------------------------------
|
|
55
|
+
py=""
|
|
56
|
+
for c in python3 python; do
|
|
57
|
+
if command -v "$c" >/dev/null 2>&1; then py="$c"; break; fi
|
|
58
|
+
done
|
|
59
|
+
[ -n "$py" ] || exit 0 # no Python -> fail open
|
|
60
|
+
|
|
61
|
+
matcher="$HOME/.cursor/skills/anti-slop/scripts/scope_match.py"
|
|
62
|
+
[ -f "$matcher" ] || exit 0 # skill not installed -> silent
|
|
63
|
+
|
|
64
|
+
mout="$("$py" "$matcher" --path "$rel" --patterns-file "$scope_file" 2>/dev/null)"
|
|
65
|
+
[ -n "$mout" ] || exit 0
|
|
66
|
+
|
|
67
|
+
# --- parse the JSON result (reuse the Python we already resolved) ----------
|
|
68
|
+
parse_result() {
|
|
69
|
+
"$py" - "$@" <<'PYEOF' 2>/dev/null
|
|
70
|
+
import json, sys
|
|
71
|
+
try:
|
|
72
|
+
p = json.loads(sys.stdin.read())
|
|
73
|
+
except Exception:
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
if p.get("skipped"):
|
|
76
|
+
sys.exit(2) # no valid contract -> fail-open
|
|
77
|
+
if p.get("in_scope"):
|
|
78
|
+
sys.exit(3) # in scope -> clean
|
|
79
|
+
allow_growth = "1" if p.get("allow_growth") else "0"
|
|
80
|
+
intent = p.get("intent", "")
|
|
81
|
+
print(f"__AG__{allow_growth}")
|
|
82
|
+
print(f"__INTENT__{intent}")
|
|
83
|
+
sys.exit(0)
|
|
84
|
+
PYEOF
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
parsed="$(printf '%s' "$mout" | parse_result)"
|
|
88
|
+
rc=$?
|
|
89
|
+
[ "$rc" -eq 0 ] || exit 0 # 2=skipped, 3=in-scope, 1=parse-fail -> all silent
|
|
90
|
+
|
|
91
|
+
allow_growth="$(printf '%s\n' "$parsed" | grep '__AG__' | sed 's/__AG__//')"
|
|
92
|
+
intent="$(printf '%s\n' "$parsed" | grep '__INTENT__' | sed 's/__INTENT__//')"
|
|
93
|
+
|
|
94
|
+
# Read declared files for the message (best-effort)
|
|
95
|
+
declared_files="$(printf '%s' "$scope_file" | "$py" -c "
|
|
96
|
+
import json, sys
|
|
97
|
+
try:
|
|
98
|
+
d = json.load(open(sys.argv[1]))
|
|
99
|
+
print(', '.join(d.get('files', [])))
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
" "$scope_file" 2>/dev/null)"
|
|
103
|
+
|
|
104
|
+
# --- compose advisory ------------------------------------------------------
|
|
105
|
+
if [ "$allow_growth" = "1" ]; then
|
|
106
|
+
summary="Scope note - $rel is new vs your declared scope (growth allowed)"
|
|
107
|
+
body=" You touched a file outside your initial declared set. Since allow_growth is
|
|
108
|
+
true, this is not a violation, but justify it: add $rel to .scope.json or
|
|
109
|
+
explain why the scope grew."
|
|
110
|
+
else
|
|
111
|
+
summary="[SCOPE VIOLATION] $rel is NOT in your declared scope"
|
|
112
|
+
body=" Your contract (.scope.json):
|
|
113
|
+
intent: $intent
|
|
114
|
+
files: $declared_files
|
|
115
|
+
|
|
116
|
+
You declared these files and touched one outside the set. Either:
|
|
117
|
+
1. Add $rel to .scope.json with a one-line justification, OR
|
|
118
|
+
2. Revert the change - it is out of scope for the declared intent.
|
|
119
|
+
|
|
120
|
+
Declared-editing: declare BEFORE you expand. Don't sneak edits past the gate."
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
msg="${summary}
|
|
124
|
+
|
|
125
|
+
${body}
|
|
126
|
+
|
|
127
|
+
(Advisory; disable: SCOPE_GATE_ENFORCE=0)"
|
|
128
|
+
|
|
129
|
+
# --- append to the shared pending file --------------------------------------
|
|
130
|
+
cid="$(safe_conversation_id "$input")"
|
|
131
|
+
pending="$(hooks_pending_dir)/feedback-${cid}.txt"
|
|
132
|
+
mkdir -p "$(dirname "$pending")" 2>/dev/null
|
|
133
|
+
if [ -s "$pending" ]; then
|
|
134
|
+
printf '\n\n---\n\n%s' "$msg" >> "$pending" 2>/dev/null
|
|
135
|
+
else
|
|
136
|
+
printf '%s' "$msg" >> "$pending" 2>/dev/null
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
exit 0
|
package/linux/hooks.json
CHANGED
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
"matcher": "^(Write|StrReplace|EditNotebook)$",
|
|
28
28
|
"_comment": "15s: semantic-opacity advisory on the edited file. Extracts added lines from git diff, pipes to density_scan.py (shared low_density module), flags identifiers that communicate no intent (DataManager, process(), utils.ts, CoreEngine). FAIL = bare low-density token or generic-suffix class without domain noun; WARN = defensible DDD with domain noun (PostgresUserRepository) - only fires alongside a FAIL so clean code stays quiet. One denylist shared with scan_slop.py's semantic_density bucket. Appends to pending; never blocks. Disable: HOOKS_ENFORCE=0 or SEMANTIC_DENSITY_ENFORCE=0."
|
|
29
29
|
},
|
|
30
|
+
{
|
|
31
|
+
"command": "bash ~/.agents/hooks/scope-gate-audit.sh",
|
|
32
|
+
"timeout": 10,
|
|
33
|
+
"matcher": "^(Write|StrReplace|EditNotebook)$",
|
|
34
|
+
"_comment": "10s (Compuerta 1): declared-scope advisory. OPT-IN: only active when .scope.json exists in the repo root. The agent declares intent + files[] + acceptance; this hook checks every edit against the declared set via scope_match.py (exact, * glob, ** recursive, bare-dir). Out-of-scope edit = [SCOPE VIOLATION] advisory to pending. No .scope.json = silent (fallback to declared-editing ladder + final-review footprint check). Never blocks (Cursor has no preToolUse for file edits). Disable: HOOKS_ENFORCE=0 or SCOPE_GATE_ENFORCE=0."
|
|
35
|
+
},
|
|
30
36
|
{
|
|
31
37
|
"command": "bash ~/.agents/hooks/anti-slop-audit.sh",
|
|
32
38
|
"timeout": 15,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursordoctrine",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Thin self-review hooks for Cursor — the model is the auditor. Intent-trace final review (Tier 0), unified 13-item anti-slop checklist, operational slop detection.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cursordoctrine": "bin/cli.mjs"
|
|
@@ -272,7 +272,11 @@ The scanner is stdlib-only and needs Python 3.9+. Pairs with the **anti-slop
|
|
|
272
272
|
audit hook** (`anti-slop-audit.ps1` / `.sh`, advisory per edit), the
|
|
273
273
|
**semantic-density-audit hook** (`semantic-density-audit.ps1` / `.sh`, flags
|
|
274
274
|
low-density identifiers per edit — shares `low_density.py` with this scanner's
|
|
275
|
-
`semantic_density` bucket), the **
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
`semantic_density` bucket), the **scope-gate-audit hook**
|
|
276
|
+
(`scope-gate-audit.ps1` / `.sh`, Compuerta 1 — opt-in declared-scope gate
|
|
277
|
+
that flags edits outside `.scope.json`; shares `scope_match.py` with the
|
|
278
|
+
final-review Step D closing gate), the **stop hook** (`final-review.ps1` / `.sh`,
|
|
279
|
+
six-axis session review incl. intent trace and wiring completeness), and
|
|
280
|
+
**declared-editing** (YAGNI ultra ladder injected at session start;
|
|
281
|
+
supersedes the deprecated `minimal-editing` size gate). This skill is the
|
|
278
282
|
active "delete it now" layer those only nudge toward.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""scope_match.py - declared-scope glob matcher (shared helper).
|
|
3
|
+
|
|
4
|
+
One job: given a repo-relative path and a list of declared-scope patterns
|
|
5
|
+
(from .scope.json `files`), return whether the path is in scope. Shared
|
|
6
|
+
between the per-edit scope-gate-audit hook (afterFileEdit) and final-review's
|
|
7
|
+
declared-scope check (Step C), so the two never disagree on what counts as
|
|
8
|
+
"in scope".
|
|
9
|
+
|
|
10
|
+
Pattern support:
|
|
11
|
+
- exact path: src/components/LoginButton.tsx
|
|
12
|
+
- glob *: src/styles/*.css (single segment, no /)
|
|
13
|
+
- glob **: src/**/test_*.py (recursive across dirs)
|
|
14
|
+
- bare dir: src/components (matches everything under it)
|
|
15
|
+
|
|
16
|
+
Stdlib only; Python 3.9+. REPORTS only - never edits.
|
|
17
|
+
|
|
18
|
+
CLI:
|
|
19
|
+
scope_match.py --path src/auth/session.ts --patterns-file .scope.json
|
|
20
|
+
-> prints JSON {"in_scope": false, "matched_by": null} and exits 0
|
|
21
|
+
-> if .scope.json is missing or unparseable, prints {"in_scope": true,
|
|
22
|
+
"skipped": "no .scope.json"} (fail-open: no contract = no gate)
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
|
|
32
|
+
# Sentinel chars for anchoring; built once, used in the regex below.
|
|
33
|
+
_ANCHOR_START = "^"
|
|
34
|
+
_ANCHOR_END = "$"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _pattern_to_regex(pattern: str) -> re.Pattern:
|
|
38
|
+
"""Convert a glob pattern to a compiled regex matching the WHOLE path.
|
|
39
|
+
|
|
40
|
+
Standard glob semantics (NOT fnmatch's):
|
|
41
|
+
** matches any chars INCLUDING / (recursive)
|
|
42
|
+
* matches any chars EXCEPT / (single segment)
|
|
43
|
+
? matches a single char EXCEPT /
|
|
44
|
+
A bare directory pattern (basename has no dot, e.g. 'src/components')
|
|
45
|
+
matches the dir AND everything beneath it.
|
|
46
|
+
"""
|
|
47
|
+
bare = pattern.rstrip("/")
|
|
48
|
+
base = os.path.basename(bare)
|
|
49
|
+
is_dir = ("." not in base)
|
|
50
|
+
|
|
51
|
+
out: list[str] = []
|
|
52
|
+
i = 0
|
|
53
|
+
while i < len(pattern):
|
|
54
|
+
c = pattern[i]
|
|
55
|
+
if c == "*" and i + 1 < len(pattern) and pattern[i + 1] == "*":
|
|
56
|
+
out.append(".*") # ** crosses /
|
|
57
|
+
i += 2
|
|
58
|
+
elif c == "*":
|
|
59
|
+
out.append("[^/]*") # * stays within a segment
|
|
60
|
+
i += 1
|
|
61
|
+
elif c == "?":
|
|
62
|
+
out.append("[^/]")
|
|
63
|
+
i += 1
|
|
64
|
+
else:
|
|
65
|
+
out.append(re.escape(c))
|
|
66
|
+
i += 1
|
|
67
|
+
body = "".join(out)
|
|
68
|
+
|
|
69
|
+
if is_dir:
|
|
70
|
+
body = body + "(/.*)?"
|
|
71
|
+
|
|
72
|
+
return re.compile(_ANCHOR_START + body + _ANCHOR_END)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def in_scope(path: str, patterns: list) -> tuple:
|
|
76
|
+
"""Return (matched_bool, matched_by_pattern_or_None)."""
|
|
77
|
+
norm = path.replace("\\", "/").lstrip("/")
|
|
78
|
+
for p in patterns:
|
|
79
|
+
p = p.strip().replace("\\", "/")
|
|
80
|
+
if not p:
|
|
81
|
+
continue
|
|
82
|
+
if _pattern_to_regex(p).match(norm):
|
|
83
|
+
return True, p
|
|
84
|
+
return False, None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_scope(scope_path: str):
|
|
88
|
+
"""Load and validate .scope.json. Returns the dict, or None if missing/
|
|
89
|
+
unparseable (fail-open: no contract = no gate fires)."""
|
|
90
|
+
if not os.path.isfile(scope_path):
|
|
91
|
+
return None
|
|
92
|
+
try:
|
|
93
|
+
with open(scope_path, "r", encoding="utf-8") as f:
|
|
94
|
+
data = json.load(f)
|
|
95
|
+
if not isinstance(data, dict):
|
|
96
|
+
return None
|
|
97
|
+
if not isinstance(data.get("files", []), list):
|
|
98
|
+
return None
|
|
99
|
+
return data
|
|
100
|
+
except (json.JSONDecodeError, OSError):
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main() -> int:
|
|
105
|
+
ap = argparse.ArgumentParser(
|
|
106
|
+
description="Declared-scope glob matcher (shared helper).")
|
|
107
|
+
ap.add_argument("--path", required=True,
|
|
108
|
+
help="repo-relative path to check")
|
|
109
|
+
ap.add_argument("--patterns-file",
|
|
110
|
+
help="path to .scope.json (default: .scope.json in cwd)")
|
|
111
|
+
ap.add_argument("--patterns",
|
|
112
|
+
help="comma-separated patterns (overrides --patterns-file)")
|
|
113
|
+
args = ap.parse_args()
|
|
114
|
+
|
|
115
|
+
if args.patterns:
|
|
116
|
+
patterns = [p.strip() for p in args.patterns.split(",") if p.strip()]
|
|
117
|
+
matched, by = in_scope(args.path, patterns)
|
|
118
|
+
result = {"in_scope": matched, "matched_by": by}
|
|
119
|
+
else:
|
|
120
|
+
scope_path = args.patterns_file or os.path.join(os.getcwd(), ".scope.json")
|
|
121
|
+
scope = load_scope(scope_path)
|
|
122
|
+
if scope is None:
|
|
123
|
+
print(json.dumps({"in_scope": True, "skipped": "no valid .scope.json"}))
|
|
124
|
+
return 0
|
|
125
|
+
patterns = [str(f) for f in scope.get("files", [])]
|
|
126
|
+
matched, by = in_scope(args.path, patterns)
|
|
127
|
+
result = {
|
|
128
|
+
"in_scope": matched,
|
|
129
|
+
"matched_by": by,
|
|
130
|
+
"allow_growth": bool(scope.get("allow_growth", False)),
|
|
131
|
+
"intent": scope.get("intent", ""),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
print(json.dumps(result))
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
sys.exit(main())
|
|
@@ -64,6 +64,17 @@ Step C — session footprint (also in the header above):
|
|
|
64
64
|
If "Session footprint" shows >5 files or the request was simple, justify each
|
|
65
65
|
file or trim. Unjustified files are slop.
|
|
66
66
|
|
|
67
|
+
Step D — declared scope (closing gate for Compuerta 1):
|
|
68
|
+
If `.scope.json` exists in the repo root, run the session's full diff against
|
|
69
|
+
the declared contract. In your shell:
|
|
70
|
+
for f in $(git diff --name-only HEAD); do
|
|
71
|
+
python ~/.cursor/skills/anti-slop/scripts/scope_match.py --path "$f" --patterns-file .scope.json
|
|
72
|
+
done
|
|
73
|
+
Any file reporting `"in_scope": false` is a scope violation you must justify
|
|
74
|
+
(add to .scope.json with a one-line reason) or revert. If `.scope.json` does
|
|
75
|
+
not exist, this step is skipped — the declared-editing ladder and the
|
|
76
|
+
per-edit scope-gate-audit hook are the opt-in discipline.
|
|
77
|
+
|
|
67
78
|
Fix with edits now; re-run the scan (if Step A ran) and the tests; then stop.
|
|
68
79
|
|
|
69
80
|
## 5. Wiring completeness
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# scope-gate-audit.ps1 - afterFileEdit "declared scope" advisory (Cursor).
|
|
2
|
+
#
|
|
3
|
+
# Compuerta 1 of the anti-slop system: the declared-scope gate. When the agent
|
|
4
|
+
# writes a .scope.json contract (intent + files[] + acceptance), this hook
|
|
5
|
+
# checks every edited file against it. Editing OUTSIDE the declared set is the
|
|
6
|
+
# textbook scope-creep / gold-plating signal - the agent is doing work it did
|
|
7
|
+
# not declare. Advisory only on Cursor (no preToolUse for file edits), but the
|
|
8
|
+
# violation is flagged on the next turn and the model must justify or revert.
|
|
9
|
+
#
|
|
10
|
+
# Opt-in: if .scope.json does not exist in the repo root, this hook is silent.
|
|
11
|
+
# Declared-editing discipline is something the agent opts into by writing the
|
|
12
|
+
# contract. No contract = no gate (fallback to declared-editing ladder + the
|
|
13
|
+
# footprint check in final-review).
|
|
14
|
+
#
|
|
15
|
+
# Mechanism: resolve edited file -> repo-relative, run scope_match.py against
|
|
16
|
+
# .scope.json's files[], append advisory to feedback-<cid>.txt on violation.
|
|
17
|
+
# Identical pattern to semantic-density-audit and anti-slop-audit.
|
|
18
|
+
#
|
|
19
|
+
# Advisory only: never blocks, never persists state, ALWAYS exits 0.
|
|
20
|
+
# Disable: HOOKS_ENFORCE=0 or SCOPE_GATE_ENFORCE=0
|
|
21
|
+
|
|
22
|
+
$ErrorActionPreference = 'SilentlyContinue'
|
|
23
|
+
. "$PSScriptRoot\hook-common.ps1"
|
|
24
|
+
|
|
25
|
+
if ($env:HOOKS_ENFORCE -eq '0' -or $env:SCOPE_GATE_ENFORCE -eq '0') { exit 0 }
|
|
26
|
+
|
|
27
|
+
$obj = Read-HookStdinJson
|
|
28
|
+
if (-not $obj) { exit 0 }
|
|
29
|
+
|
|
30
|
+
# audit root: project from JSON (cwd, then workspace_roots), else CURSOR_PROJECT_DIR / HOME
|
|
31
|
+
$root = ''
|
|
32
|
+
$cands = @()
|
|
33
|
+
if ($obj.PSObject.Properties['cwd'] -and $obj.cwd) { $cands += [string]$obj.cwd }
|
|
34
|
+
if ($obj.PSObject.Properties['workspace_roots']) { foreach ($w in $obj.workspace_roots) { $cands += [string]$w } }
|
|
35
|
+
foreach ($c in $cands) { $f = ConvertTo-FwdPath $c; if ($f -and (Test-Path -LiteralPath $f)) { $root = $f.TrimEnd('/'); break } }
|
|
36
|
+
if (-not $root) { $root = (& { if ($env:CURSOR_PROJECT_DIR) { $env:CURSOR_PROJECT_DIR } else { $HOME } }).Replace('\', '/').TrimEnd('/') }
|
|
37
|
+
|
|
38
|
+
# edited file -> repo-relative forward-slash path
|
|
39
|
+
$fp = ''
|
|
40
|
+
foreach ($k in 'file_path', 'path', 'filename', 'absolute_path', 'abs_path') {
|
|
41
|
+
if ($obj.PSObject.Properties[$k] -and $obj.$k) { $fp = [string]$obj.$k; break }
|
|
42
|
+
}
|
|
43
|
+
if (-not $fp) { exit 0 }
|
|
44
|
+
$rel = ConvertTo-FwdPath $fp
|
|
45
|
+
if ($rel.StartsWith($root + '/', [System.StringComparison] 'OrdinalIgnoreCase')) { $rel = $rel.Substring($root.Length + 1) }
|
|
46
|
+
if (Test-IsCursorConfigPath $fp) { exit 0 }
|
|
47
|
+
if (Test-IsCursorConfigPath $rel) { exit 0 }
|
|
48
|
+
|
|
49
|
+
# --- opt-in gate: no .scope.json = no gate ---------------------------------
|
|
50
|
+
$scopeFile = "$root/.scope.json"
|
|
51
|
+
if (-not (Test-Path -LiteralPath $scopeFile)) { exit 0 }
|
|
52
|
+
|
|
53
|
+
# --- resolve Python + run scope_match.py -----------------------------------
|
|
54
|
+
$py = Get-Command python, python3, py -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
55
|
+
if (-not $py) { exit 0 } # no Python -> fail open
|
|
56
|
+
|
|
57
|
+
$matcher = Join-Path $HOME '.cursor\skills\anti-slop\scripts\scope_match.py'
|
|
58
|
+
if (-not (Test-Path $matcher)) { exit 0 } # skill not installed -> silent
|
|
59
|
+
|
|
60
|
+
$mout = & $py.Source $matcher --path $rel --patterns-file $scopeFile 2>$null
|
|
61
|
+
if (-not $mout) { exit 0 }
|
|
62
|
+
|
|
63
|
+
$payload = $null
|
|
64
|
+
try { $payload = ($mout -join "`n") | ConvertFrom-Json } catch { }
|
|
65
|
+
if (-not $payload) { exit 0 }
|
|
66
|
+
|
|
67
|
+
# fail-open: if scope_match reported skipped (no valid contract), stay silent
|
|
68
|
+
$hasSkipped = $false
|
|
69
|
+
try { if ($payload.PSObject.Properties['skipped']) { $hasSkipped = $true } } catch { }
|
|
70
|
+
if ($hasSkipped) { exit 0 }
|
|
71
|
+
|
|
72
|
+
$inScope = $false
|
|
73
|
+
try { $inScope = [bool]$payload.in_scope } catch { }
|
|
74
|
+
if ($inScope) { exit 0 }
|
|
75
|
+
|
|
76
|
+
# --- violation: compose advisory -------------------------------------------
|
|
77
|
+
$allowGrowth = $false
|
|
78
|
+
if ($payload.PSObject.Properties['allow_growth'] -and $payload.allow_growth) { $allowGrowth = $true }
|
|
79
|
+
$intent = ''
|
|
80
|
+
if ($payload.PSObject.Properties['intent']) { $intent = [string]$payload.intent }
|
|
81
|
+
|
|
82
|
+
# Read the declared files list for the message (best-effort; skip on failure)
|
|
83
|
+
$declaredFiles = ''
|
|
84
|
+
try {
|
|
85
|
+
$scopeJson = Get-Content -LiteralPath $scopeFile -Raw | ConvertFrom-Json
|
|
86
|
+
if ($scopeJson.files) { $declaredFiles = ($scopeJson.files -join ', ') }
|
|
87
|
+
} catch { }
|
|
88
|
+
|
|
89
|
+
if ($allowGrowth) {
|
|
90
|
+
# Growth is allowed: informational, not a violation
|
|
91
|
+
$summary = "Scope note - $rel is new vs your declared scope (growth allowed)"
|
|
92
|
+
$body = @"
|
|
93
|
+
You touched a file outside your initial declared set. Since allow_growth is
|
|
94
|
+
true, this is not a violation, but justify it: add $rel to .scope.json or
|
|
95
|
+
explain why the scope grew.
|
|
96
|
+
"@
|
|
97
|
+
} else {
|
|
98
|
+
# Hard violation: edited outside the declared contract
|
|
99
|
+
$summary = "[SCOPE VIOLATION] $rel is NOT in your declared scope"
|
|
100
|
+
$body = @"
|
|
101
|
+
Your contract (.scope.json):
|
|
102
|
+
intent: $intent
|
|
103
|
+
files: $declaredFiles
|
|
104
|
+
|
|
105
|
+
You declared these files and touched one outside the set. Either:
|
|
106
|
+
1. Add $rel to .scope.json with a one-line justification, OR
|
|
107
|
+
2. Revert the change - it is out of scope for the declared intent.
|
|
108
|
+
|
|
109
|
+
Declared-editing: declare BEFORE you expand. Don't sneak edits past the gate.
|
|
110
|
+
"@
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
$msg = "$summary`n`n$body`n`n(Advisory; disable: SCOPE_GATE_ENFORCE=0)"
|
|
114
|
+
|
|
115
|
+
# --- append to the shared pending file --------------------------------------
|
|
116
|
+
$cid = Get-SafeConversationId $obj
|
|
117
|
+
$pending = Join-Path (Get-HooksPendingDir) "feedback-$cid.txt"
|
|
118
|
+
try {
|
|
119
|
+
New-Item -ItemType Directory -Path (Split-Path $pending) -Force | Out-Null
|
|
120
|
+
$prefix = ''
|
|
121
|
+
if ((Test-Path $pending) -and ((Get-Item $pending).Length -gt 0)) { $prefix = "`n`n---`n`n" }
|
|
122
|
+
Add-Content -Path $pending -Value ($prefix + $msg) -NoNewline
|
|
123
|
+
} catch { }
|
|
124
|
+
|
|
125
|
+
exit 0
|
package/windows/hooks.json
CHANGED
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
"matcher": "^(Write|StrReplace|EditNotebook)$",
|
|
28
28
|
"_comment": "15s: semantic-opacity advisory on the edited file. Extracts added lines from git diff, pipes to density_scan.py (shared low_density module), flags identifiers that communicate no intent (DataManager, process(), utils.ts, CoreEngine). FAIL = bare low-density token or generic-suffix class without domain noun; WARN = defensible DDD with domain noun (PostgresUserRepository) - only fires alongside a FAIL so clean code stays quiet. One denylist shared with scan_slop.py's semantic_density bucket. Appends to pending; never blocks. Disable: HOOKS_ENFORCE=0 or SEMANTIC_DENSITY_ENFORCE=0."
|
|
29
29
|
},
|
|
30
|
+
{
|
|
31
|
+
"command": "pwsh.exe -NoProfile -File ~/.agents/hooks/scope-gate-audit.ps1",
|
|
32
|
+
"timeout": 10,
|
|
33
|
+
"matcher": "^(Write|StrReplace|EditNotebook)$",
|
|
34
|
+
"_comment": "10s (Compuerta 1): declared-scope advisory. OPT-IN: only active when .scope.json exists in the repo root. The agent declares intent + files[] + acceptance; this hook checks every edit against the declared set via scope_match.py (exact, * glob, ** recursive, bare-dir). Out-of-scope edit = [SCOPE VIOLATION] advisory to pending. No .scope.json = silent (fallback to declared-editing ladder + final-review footprint check). Never blocks (Cursor has no preToolUse for file edits). Disable: HOOKS_ENFORCE=0 or SCOPE_GATE_ENFORCE=0."
|
|
35
|
+
},
|
|
30
36
|
{
|
|
31
37
|
"command": "pwsh.exe -NoProfile -File ~/.agents/hooks/anti-slop-audit.ps1",
|
|
32
38
|
"timeout": 15,
|