cursordoctrine 0.5.3 → 0.5.5
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/bin/cli.mjs +19 -0
- package/linux/hooks/final-review.md +13 -0
- package/linux/hooks/intent-anchor.sh +54 -29
- package/linux/hooks/scope-gate-audit.sh +45 -110
- package/linux/hooks.json +1 -1
- package/package.json +1 -1
- package/windows/hooks/final-review.md +13 -0
- package/windows/hooks/intent-anchor.ps1 +47 -27
- package/windows/hooks/scope-gate-audit.ps1 +54 -93
- package/windows/hooks.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -334,6 +334,25 @@ function verify() {
|
|
|
334
334
|
JSON.stringify({ role: 'user', message: { content: `<user_query>${q}</user_query>` } }) + '\n',
|
|
335
335
|
'utf8');
|
|
336
336
|
|
|
337
|
+
// --- Case 0: no .scope.json + NO transcript -> WRITE scaffold anyway -------
|
|
338
|
+
// This is the 0.5.3 regression: creation was gated on $hasQuery, so when
|
|
339
|
+
// Cursor didn't surface transcript_path in the first postToolUse fire, the
|
|
340
|
+
// scope never appeared. Now creation is unconditional on a real root.
|
|
341
|
+
runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: repoDir });
|
|
342
|
+
let d0 = drainedOf(anchorCid);
|
|
343
|
+
if (!existsSync(scopePath)) {
|
|
344
|
+
cleanup(); return { ok: false, detail: 'scaffold NOT created without transcript (0.5.3 regression)' };
|
|
345
|
+
}
|
|
346
|
+
let scope0;
|
|
347
|
+
try { scope0 = JSON.parse(readFileSync(scopePath, 'utf8')); }
|
|
348
|
+
catch { cleanup(); return { ok: false, detail: '.scope.json (no-transcript) is not valid JSON' }; }
|
|
349
|
+
if (!scope0.intent || !scope0.intent.includes('TODO')) {
|
|
350
|
+
cleanup(); return { ok: false, detail: `no-transcript scaffold should have intent <TODO>, got: ${scope0.intent}` };
|
|
351
|
+
}
|
|
352
|
+
// Clear the latch + scope so Case A starts fresh (with a real query this time).
|
|
353
|
+
runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
|
|
354
|
+
cleanup();
|
|
355
|
+
|
|
337
356
|
// --- Case A: no .scope.json + prompt q1 -> WRITE scaffold with q1 as intent -
|
|
338
357
|
writeTranscript(q1);
|
|
339
358
|
runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: repoDir, transcript_path: transcriptPath });
|
|
@@ -122,5 +122,18 @@ Determinism / purity:
|
|
|
122
122
|
- In-place mutation of shared state (arr.push, obj.prop =) when a caller holds
|
|
123
123
|
a reference -> return new structures ([...arr, x], .map/.filter).
|
|
124
124
|
|
|
125
|
+
Logic & structure:
|
|
126
|
+
- Arrow code: >2 levels of nested if/for -> flatten with guard clauses
|
|
127
|
+
(early returns). Code reads top-to-bottom, no deep indent.
|
|
128
|
+
- Switch/if-else bloat: a switch or 5+ if/else branches -> Map/dispatch
|
|
129
|
+
(Record<State, fn>) or the Command pattern.
|
|
130
|
+
- Mixed abstraction (SLAP): a function mixing DB calls + string validation +
|
|
131
|
+
date formatting -> one level of abstraction per function; extract helpers.
|
|
132
|
+
- Primitive obsession: a primitive with business rules (email, userId, chainId)
|
|
133
|
+
passed as a bare string/number across functions -> a named type/value object.
|
|
134
|
+
- Imperative transforms: a `for` loop building an array when the language has
|
|
135
|
+
.map/.filter/.reduce -> use the declarative form; reserve `for` for cases
|
|
136
|
+
map/reduce cannot express.
|
|
137
|
+
|
|
125
138
|
You do NOT need to run a tool for these — read the diff and apply the named fix.
|
|
126
139
|
If none apply, say so in one line.
|
|
@@ -18,11 +18,13 @@
|
|
|
18
18
|
# 2. AUTO-CREATE / REGENERATE .scope.json: when the current <user_query>
|
|
19
19
|
# differs from the contract on disk (no contract yet, OR _intent_hash
|
|
20
20
|
# mismatch), the hook WRITES a scaffold to the REPO ROOT: intent locked
|
|
21
|
-
# from the prompt, files
|
|
22
|
-
#
|
|
23
|
-
# a
|
|
24
|
-
#
|
|
25
|
-
#
|
|
21
|
+
# from the prompt, files as an EMPTY array (scope-gate-audit.sh fills it
|
|
22
|
+
# mechanically as the agent edits - the agent never maintains files[] by
|
|
23
|
+
# hand), acceptance as a TODO the agent sets. This is the user-requested
|
|
24
|
+
# behavior: every new prompt -> a fresh .scope.json the agent works from.
|
|
25
|
+
# Fixed vs the broken 0.4.4 build: never writes to $HOME (bails if no real
|
|
26
|
+
# root resolves -> no ghost files), regenerates on prompt CHANGE not just
|
|
27
|
+
# on absence.
|
|
26
28
|
# 3. RE-INJECT on same-prompt turns: when the query is unchanged (contract
|
|
27
29
|
# already current), the hook re-injects the existing contract into the
|
|
28
30
|
# feedback bus so it stays in the model's attentional focus each turn.
|
|
@@ -56,6 +58,16 @@ pending_dir="$(hooks_pending_dir)"
|
|
|
56
58
|
latch="$pending_dir/intent-injected-$cid.flag"
|
|
57
59
|
hash_file="$pending_dir/last-query-$cid.hash"
|
|
58
60
|
|
|
61
|
+
# Stale-latch defense: if a previous session died mid-turn without hitting
|
|
62
|
+
# stop (Cursor crash, force-quit), the latch can persist and silence this hook
|
|
63
|
+
# for the whole next session -> scope never gets created. If the latch is older
|
|
64
|
+
# than 2 hours, treat it as orphaned and clear it. Normal clears happen at
|
|
65
|
+
# every stop (final-review.sh); this is the backstop for abnormal terminations.
|
|
66
|
+
if [ -f "$latch" ]; then
|
|
67
|
+
age_hours=$(( ($(date +%s) - $(stat -c %Y "$latch" 2>/dev/null || stat -f %m "$latch" 2>/dev/null || echo 0)) / 3600 ))
|
|
68
|
+
[ "$age_hours" -ge 2 ] && rm -f "$latch" 2>/dev/null
|
|
69
|
+
fi
|
|
70
|
+
|
|
59
71
|
# Already injected this turn -> quiet. Latch cleared at every stop.
|
|
60
72
|
[ -f "$latch" ] && exit 0
|
|
61
73
|
|
|
@@ -131,34 +143,42 @@ EOF
|
|
|
131
143
|
fi
|
|
132
144
|
fi
|
|
133
145
|
|
|
134
|
-
# --- auto-create / regenerate .scope.json
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
146
|
+
# --- auto-create / regenerate .scope.json -----------------------------------
|
|
147
|
+
# CREATION does NOT require the query: if there's a root and no scope yet,
|
|
148
|
+
# scaffold it NOW with intent=<TODO> (the agent fills it from the chat it's
|
|
149
|
+
# already responding to). This was the 0.5.3 bug - creation was gated on
|
|
150
|
+
# $hasQuery, so when Cursor didn't surface transcript_path in the first
|
|
151
|
+
# postToolUse fire, the scope never got created.
|
|
152
|
+
# REGENERATION does require the query: we can only detect a prompt change if
|
|
153
|
+
# we can hash the current request. Without a query we leave an existing scope
|
|
154
|
+
# alone (re-inject it) rather than blank it.
|
|
140
155
|
regenerated=0
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
156
|
+
should_create=0
|
|
157
|
+
should_regen=0
|
|
158
|
+
[ "$scope_exists" != "1" ] && should_create=1
|
|
159
|
+
if [ "$has_query" = "1" ] && [ "$scope_exists" = "1" ] && [ "$scope_stale" = "1" ]; then
|
|
160
|
+
should_regen=1
|
|
146
161
|
fi
|
|
147
162
|
|
|
148
|
-
if [ "$
|
|
149
|
-
#
|
|
150
|
-
|
|
151
|
-
|
|
163
|
+
if [ "$should_create" = "1" ] || [ "$should_regen" = "1" ]; then
|
|
164
|
+
# intent from the query when available, else a TODO for the agent to fill.
|
|
165
|
+
if [ "$has_query" = "1" ]; then
|
|
166
|
+
intent_val="$current_query"
|
|
167
|
+
else
|
|
168
|
+
intent_val="<TODO: state the operational objective - what is strictly necessary>"
|
|
169
|
+
fi
|
|
170
|
+
# jq preferred; python3 fallback. Write intent, empty files[], TODO
|
|
171
|
+
# acceptance, and record _intent_hash so staleness is self-contained.
|
|
152
172
|
if have_jq; then
|
|
153
|
-
jq -n --arg intent "$
|
|
154
|
-
'{intent:$intent, files:[
|
|
173
|
+
jq -n --arg intent "$intent_val" --arg hash "$current_hash" \
|
|
174
|
+
'{intent:$intent, files:[], acceptance:"<TODO: the one deterministic check that decides done>", allow_growth:false, _intent_hash:$hash, _generated_by:"intent-anchor hook"}' \
|
|
155
175
|
> "$scope_path" 2>/dev/null && regenerated=1
|
|
156
176
|
elif have_py; then
|
|
157
|
-
if I_FILE="$scope_path" I_INTENT="$
|
|
177
|
+
if I_FILE="$scope_path" I_INTENT="$intent_val" I_HASH="$current_hash" python3 -c '
|
|
158
178
|
import json, os
|
|
159
179
|
obj = {
|
|
160
180
|
"intent": os.environ["I_INTENT"],
|
|
161
|
-
"files": [
|
|
181
|
+
"files": [],
|
|
162
182
|
"acceptance": "<TODO: the one deterministic check that decides done>",
|
|
163
183
|
"allow_growth": False,
|
|
164
184
|
"_intent_hash": os.environ["I_HASH"],
|
|
@@ -171,14 +191,18 @@ with open(os.environ["I_FILE"], "w", encoding="utf-8") as f:
|
|
|
171
191
|
fi
|
|
172
192
|
fi
|
|
173
193
|
if [ "$regenerated" = "1" ]; then
|
|
174
|
-
scope_intent="$
|
|
194
|
+
scope_intent="$intent_val"
|
|
175
195
|
scope_acceptance="<TODO: the one deterministic check that decides done>"
|
|
176
|
-
scope_files="
|
|
196
|
+
scope_files="(auto-tracked - the scope hook records every file you edit)"
|
|
177
197
|
scope_exists=1
|
|
178
198
|
scope_stale=0
|
|
179
199
|
fi
|
|
180
200
|
fi
|
|
181
201
|
|
|
202
|
+
# files[] is auto-tracked and starts empty; show something readable until the
|
|
203
|
+
# scope hook has recorded the first edit.
|
|
204
|
+
[ -n "$scope_files" ] || scope_files="(none yet - auto-tracked as you edit)"
|
|
205
|
+
|
|
182
206
|
# --- compose the anchor message ---------------------------------------------
|
|
183
207
|
# Three states: regenerated this turn (new prompt), no contract (and no query
|
|
184
208
|
# to scaffold from), or re-injecting an existing current contract.
|
|
@@ -196,9 +220,10 @@ if [ "$regenerated" = "1" ]; then
|
|
|
196
220
|
acceptance: $scope_acceptance
|
|
197
221
|
|
|
198
222
|
The hook wrote a fresh scaffold to $scope_path from your current request. intent
|
|
199
|
-
is locked from what you just asked.
|
|
200
|
-
|
|
201
|
-
|
|
223
|
+
is locked from what you just asked. files[] is AUTO-TRACKED - the scope hook
|
|
224
|
+
records every file you edit, so do not maintain it by hand. Set acceptance to
|
|
225
|
+
the one deterministic check that decides done, THEN proceed. This contract will
|
|
226
|
+
be re-injected every turn until your request changes again."
|
|
202
227
|
elif [ "$scope_exists" != "1" ]; then
|
|
203
228
|
msg="INTENT ANCHOR (pre-compile) - no .scope.json found in $root, and the current
|
|
204
229
|
request was unavailable to scaffold from.
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# scope-gate-audit.sh - afterFileEdit "
|
|
2
|
+
# scope-gate-audit.sh - afterFileEdit "scope auto-record" (Cursor, Linux).
|
|
3
3
|
#
|
|
4
|
-
# Compuerta 1
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
4
|
+
# Compuerta 1, mechanical edition: keep .scope.json's files[] in sync with what
|
|
5
|
+
# the agent ACTUALLY edits, with ZERO reliance on the model remembering to fill
|
|
6
|
+
# it. intent-anchor.sh writes the scaffold (intent locked from the prompt,
|
|
7
|
+
# files: [], acceptance: TODO); THIS hook appends every edited file to files[]
|
|
8
|
+
# as the edit happens. Net effect: the contract's files[] is always an accurate
|
|
9
|
+
# ledger of the session footprint, which final-review audits against intent.
|
|
9
10
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
11
|
+
# This REPLACES the old declared-scope VIOLATION advisory. When every edit is
|
|
12
|
+
# auto-recorded, an edit can never be "out of declared scope" - there is nothing
|
|
13
|
+
# to violate. The gate became a recorder. acceptance stays the model's to fill.
|
|
12
14
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# Advisory only: never blocks, never persists state, ALWAYS exits 0.
|
|
15
|
+
# Opt-in: silent if .scope.json does not exist in the repo root. Rewrites ONLY
|
|
16
|
+
# files[]; every other field is preserved. jq preferred, python3 fallback; if
|
|
17
|
+
# neither is present we fail open (no JSON tool = no record). ALWAYS exits 0.
|
|
17
18
|
# Disable: HOOKS_ENFORCE=0 or SCOPE_GATE_ENFORCE=0
|
|
18
19
|
|
|
19
20
|
set +e
|
|
@@ -45,111 +46,45 @@ done
|
|
|
45
46
|
[ -n "$fp" ] || exit 0
|
|
46
47
|
rel="$fp"
|
|
47
48
|
case "$rel" in "$root"/*) rel="${rel#"$root"/}" ;; esac
|
|
49
|
+
rel="${rel#/}"
|
|
48
50
|
if is_cursor_config_path "$fp" || is_cursor_config_path "$rel"; then exit 0; fi
|
|
51
|
+
# Never record the contract file into itself.
|
|
52
|
+
[ "$rel" = ".scope.json" ] && exit 0
|
|
49
53
|
|
|
50
|
-
# --- opt-in gate: no .scope.json =
|
|
54
|
+
# --- opt-in gate: no .scope.json = nothing to maintain ---------------------
|
|
51
55
|
scope_file="$root/.scope.json"
|
|
52
56
|
[ -f "$scope_file" ] || exit 0
|
|
53
57
|
|
|
54
|
-
# ---
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
58
|
+
# --- auto-record $rel into files[] (jq preferred, python3 fallback) --------
|
|
59
|
+
# Clean the existing list (drop the scaffold placeholder + blanks), then add
|
|
60
|
+
# this edit if absent. Write only when the resulting files[] actually changed,
|
|
61
|
+
# so repeat edits of the same file do not churn the contract.
|
|
62
|
+
jq_prog='((.files // []) | map(select(type=="string" and . != "" and (startswith("<TODO")|not)))) as $c'
|
|
63
|
+
|
|
64
|
+
if have_jq; then
|
|
65
|
+
old_files="$(jq -c '.files // []' "$scope_file" 2>/dev/null)"
|
|
66
|
+
new_files="$(jq -c --arg rel "$rel" "$jq_prog | (if (\$c | index(\$rel)) then \$c else \$c + [\$rel] end)" "$scope_file" 2>/dev/null)"
|
|
67
|
+
[ -n "$new_files" ] || exit 0
|
|
68
|
+
[ "$new_files" = "$old_files" ] && exit 0
|
|
69
|
+
updated="$(jq --arg rel "$rel" "$jq_prog | .files = (if (\$c | index(\$rel)) then \$c else \$c + [\$rel] end)" "$scope_file" 2>/dev/null)"
|
|
70
|
+
[ -n "$updated" ] && printf '%s\n' "$updated" > "$scope_file"
|
|
71
|
+
elif have_py; then
|
|
72
|
+
I_FILE="$scope_file" I_REL="$rel" python3 -c '
|
|
73
|
+
import json, os, sys
|
|
74
|
+
path = os.environ["I_FILE"]; rel = os.environ["I_REL"]
|
|
71
75
|
try:
|
|
72
|
-
|
|
76
|
+
d = json.load(open(path, encoding="utf-8"))
|
|
73
77
|
except Exception:
|
|
74
|
-
sys.exit(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
print(f"__ACCEPT__{acceptance}")
|
|
85
|
-
sys.exit(0)
|
|
86
|
-
PYEOF
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
parsed="$(printf '%s' "$mout" | parse_result)"
|
|
90
|
-
rc=$?
|
|
91
|
-
[ "$rc" -eq 0 ] || exit 0 # 2=skipped, 3=in-scope, 1=parse-fail -> all silent
|
|
92
|
-
|
|
93
|
-
allow_growth="$(printf '%s\n' "$parsed" | grep '__AG__' | sed 's/__AG__//')"
|
|
94
|
-
intent="$(printf '%s\n' "$parsed" | grep '__INTENT__' | sed 's/__INTENT__//')"
|
|
95
|
-
acceptance="$(printf '%s\n' "$parsed" | grep '__ACCEPT__' | sed 's/__ACCEPT__//')"
|
|
96
|
-
|
|
97
|
-
# Read declared files for the message (best-effort)
|
|
98
|
-
declared_files="$(printf '%s' "$scope_file" | "$py" -c "
|
|
99
|
-
import json, sys
|
|
100
|
-
try:
|
|
101
|
-
d = json.load(open(sys.argv[1]))
|
|
102
|
-
print(', '.join(d.get('files', [])))
|
|
103
|
-
except Exception:
|
|
104
|
-
pass
|
|
105
|
-
" "$scope_file" 2>/dev/null)"
|
|
106
|
-
|
|
107
|
-
# --- compose advisory ------------------------------------------------------
|
|
108
|
-
# acceptance line: only quote it when the agent declared one. A blank acceptance
|
|
109
|
-
# means the Anchor Set was incomplete - surface that gap, since the whole point
|
|
110
|
-
# of the pre-compile phase is to name the deterministic success check.
|
|
111
|
-
if [ -n "$acceptance" ]; then
|
|
112
|
-
acceptance_line="$acceptance"
|
|
113
|
-
else
|
|
114
|
-
acceptance_line="(not declared - your Anchor Set is missing the EXITO/acceptance field)"
|
|
115
|
-
fi
|
|
116
|
-
|
|
117
|
-
if [ "$allow_growth" = "1" ]; then
|
|
118
|
-
summary="Scope note - $rel is new vs your declared scope (growth allowed)"
|
|
119
|
-
body=" You touched a file outside your initial declared set. Since allow_growth is
|
|
120
|
-
true, this is not a violation, but justify it: add $rel to .scope.json or
|
|
121
|
-
explain why the scope grew.
|
|
122
|
-
|
|
123
|
-
Your success contract (acceptance): $acceptance_line
|
|
124
|
-
Does growing into $rel still serve that?"
|
|
125
|
-
else
|
|
126
|
-
summary="[SCOPE VIOLATION] $rel is NOT in your declared scope"
|
|
127
|
-
body=" Your contract (.scope.json):
|
|
128
|
-
intent: $intent
|
|
129
|
-
files: $declared_files
|
|
130
|
-
acceptance: $acceptance_line
|
|
131
|
-
|
|
132
|
-
You declared these files and touched one outside the set. Either:
|
|
133
|
-
1. Add $rel to .scope.json with a one-line justification, OR
|
|
134
|
-
2. Revert the change - it is out of scope for the declared intent.
|
|
135
|
-
|
|
136
|
-
Declared-editing: declare BEFORE you expand. Don't sneak edits past the gate."
|
|
137
|
-
fi
|
|
138
|
-
|
|
139
|
-
msg="${summary}
|
|
140
|
-
|
|
141
|
-
${body}
|
|
142
|
-
|
|
143
|
-
(Advisory; disable: SCOPE_GATE_ENFORCE=0)"
|
|
144
|
-
|
|
145
|
-
# --- append to the shared pending file --------------------------------------
|
|
146
|
-
cid="$(safe_conversation_id "$input")"
|
|
147
|
-
pending="$(hooks_pending_dir)/feedback-${cid}.txt"
|
|
148
|
-
mkdir -p "$(dirname "$pending")" 2>/dev/null
|
|
149
|
-
if [ -s "$pending" ]; then
|
|
150
|
-
printf '\n\n---\n\n%s' "$msg" >> "$pending" 2>/dev/null
|
|
151
|
-
else
|
|
152
|
-
printf '%s' "$msg" >> "$pending" 2>/dev/null
|
|
78
|
+
sys.exit(0)
|
|
79
|
+
files = d.get("files", []) or []
|
|
80
|
+
clean = [f for f in files if isinstance(f, str) and f and not f.startswith("<TODO")]
|
|
81
|
+
new = clean if rel in clean else clean + [rel]
|
|
82
|
+
if new == files:
|
|
83
|
+
sys.exit(0)
|
|
84
|
+
d["files"] = new
|
|
85
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
86
|
+
json.dump(d, f, ensure_ascii=False, indent=2)
|
|
87
|
+
' 2>/dev/null
|
|
153
88
|
fi
|
|
154
89
|
|
|
155
90
|
exit 0
|
package/linux/hooks.json
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"command": "bash ~/.agents/hooks/scope-gate-audit.sh",
|
|
26
26
|
"timeout": 10,
|
|
27
27
|
"matcher": "^(Write|StrReplace|EditNotebook)$",
|
|
28
|
-
"_comment": "10s (Compuerta 1):
|
|
28
|
+
"_comment": "10s (Compuerta 1, mechanical): scope auto-record. OPT-IN: only active when .scope.json exists in the repo root. intent-anchor scaffolds files:[]; this hook APPENDS every edited file to files[] (drops the scaffold placeholder, dedups, preserves all other fields) via jq with a python3 fallback, so files[] is always an accurate ledger of the session footprint that final-review audits against intent. No model effort required - the agent never maintains files[] by hand. Replaces the old [SCOPE VIOLATION] advisory (with auto-record an edit can never be out-of-declared-scope). No JSON tool / no .scope.json = silent. Never blocks, always exits 0. Disable: HOOKS_ENFORCE=0 or SCOPE_GATE_ENFORCE=0."
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
"command": "bash ~/.agents/hooks/anti-slop-audit.sh",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursordoctrine",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "Thin self-review hooks for Cursor — the model is the auditor. Pruned + deduplicated: intent-anchor (auto-scaffolded .scope.json per prompt + per-turn re-injection against Salience Dilution), intent-trace final review, unified anti-slop checklist as single source of truth.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cursordoctrine": "bin/cli.mjs"
|
|
@@ -122,5 +122,18 @@ Determinism / purity:
|
|
|
122
122
|
- In-place mutation of shared state (arr.push, obj.prop =) when a caller holds
|
|
123
123
|
a reference -> return new structures ([...arr, x], .map/.filter).
|
|
124
124
|
|
|
125
|
+
Logic & structure:
|
|
126
|
+
- Arrow code: >2 levels of nested if/for -> flatten with guard clauses
|
|
127
|
+
(early returns). Code reads top-to-bottom, no deep indent.
|
|
128
|
+
- Switch/if-else bloat: a switch or 5+ if/else branches -> Map/dispatch
|
|
129
|
+
(Record<State, fn>) or the Command pattern.
|
|
130
|
+
- Mixed abstraction (SLAP): a function mixing DB calls + string validation +
|
|
131
|
+
date formatting -> one level of abstraction per function; extract helpers.
|
|
132
|
+
- Primitive obsession: a primitive with business rules (email, userId, chainId)
|
|
133
|
+
passed as a bare string/number across functions -> a named type/value object.
|
|
134
|
+
- Imperative transforms: a `for` loop building an array when the language has
|
|
135
|
+
.map/.filter/.reduce -> use the declarative form; reserve `for` for cases
|
|
136
|
+
map/reduce cannot express.
|
|
137
|
+
|
|
125
138
|
You do NOT need to run a tool for these — read the diff and apply the named fix.
|
|
126
139
|
If none apply, say so in one line.
|
|
@@ -14,15 +14,20 @@
|
|
|
14
14
|
# at the START of each turn's work, before edits pile up and dilute the
|
|
15
15
|
# original intent. Works UNCONDITIONALLY - no transcript needed.
|
|
16
16
|
#
|
|
17
|
-
# 2. AUTO-CREATE
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
17
|
+
# 2. AUTO-CREATE .scope.json (UNCONDITIONAL on a real root): if no valid
|
|
18
|
+
# contract exists in the repo root, WRITE one now - intent locked from the
|
|
19
|
+
# query when available, otherwise `intent: <TODO>` for the agent to fill.
|
|
20
|
+
# Creation does NOT require transcript_path; only regeneration does. This
|
|
21
|
+
# was the 0.5.3 bug: creation was gated on $hasQuery, so when Cursor didn't
|
|
22
|
+
# surface the transcript on the first postToolUse fire, the scope never
|
|
23
|
+
# appeared and the agent had no contract to work from.
|
|
24
|
+
# 3. REGENERATE on prompt CHANGE: when the current <user_query> hash differs
|
|
25
|
+
# from the contract's _intent_hash, overwrite the scaffold with the new
|
|
26
|
+
# intent + empty files + TODO acceptance. Requires $hasQuery (you can only
|
|
27
|
+
# detect a change if you can read the request). Fixed vs the broken 0.4.4
|
|
23
28
|
# build: never writes to $HOME (bails if no real root resolves -> no
|
|
24
|
-
# ghost files)
|
|
25
|
-
#
|
|
29
|
+
# ghost files).
|
|
30
|
+
# 4. RE-INJECT on same-prompt turns: when the query is unchanged (contract
|
|
26
31
|
# already current), the hook re-injects the existing contract into the
|
|
27
32
|
# feedback bus so it stays in the model's attentional focus each turn.
|
|
28
33
|
#
|
|
@@ -54,6 +59,16 @@ $pendingDir = Get-HooksPendingDir
|
|
|
54
59
|
$latch = Join-Path $pendingDir "intent-injected-$cid.flag"
|
|
55
60
|
$hashFile = Join-Path $pendingDir "last-query-$cid.hash"
|
|
56
61
|
|
|
62
|
+
# Stale-latch defense: if a previous session died mid-turn without hitting
|
|
63
|
+
# stop (Cursor crash, force-quit), the latch can persist and silence this hook
|
|
64
|
+
# for the whole next session -> scope never gets created. If the latch is older
|
|
65
|
+
# than 2 hours, treat it as orphaned and clear it. Normal clears happen at
|
|
66
|
+
# every stop (final-review.ps1); this is the backstop for abnormal terminations.
|
|
67
|
+
if (Test-Path $latch) {
|
|
68
|
+
$age = (Get-Date) - (Get-Item $latch).LastWriteTime
|
|
69
|
+
if ($age.TotalHours -ge 2) { Remove-Item $latch -Force -ErrorAction SilentlyContinue }
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
# Already injected this turn -> quiet. Latch cleared at every stop.
|
|
58
73
|
if (Test-Path $latch) { exit 0 }
|
|
59
74
|
|
|
@@ -101,7 +116,8 @@ if (Test-Path -LiteralPath $scopePath) {
|
|
|
101
116
|
$sj = Get-Content -LiteralPath $scopePath -Raw | ConvertFrom-Json
|
|
102
117
|
if ($sj.intent) { $scopeIntent = [string]$sj.intent }
|
|
103
118
|
if ($sj.acceptance) { $scopeAcceptance = [string]$sj.acceptance }
|
|
104
|
-
if ($sj.files) { $scopeFiles = ($sj.files -join ', ') }
|
|
119
|
+
if ($sj.files) { $scopeFiles = (@($sj.files) -join ', ') }
|
|
120
|
+
if ([string]::IsNullOrWhiteSpace($scopeFiles)) { $scopeFiles = '(none yet - auto-tracked as you edit)' }
|
|
105
121
|
$scopeExists = $true
|
|
106
122
|
# The contract is "stale" if its recorded intent hash != current query
|
|
107
123
|
# hash. We persist the query hash inside .scope.json under _intent_hash
|
|
@@ -112,22 +128,25 @@ if (Test-Path -LiteralPath $scopePath) {
|
|
|
112
128
|
} catch { $scopeExists = $false } # malformed JSON -> treat as missing
|
|
113
129
|
}
|
|
114
130
|
|
|
115
|
-
# --- auto-create / regenerate .scope.json
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
# the
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
131
|
+
# --- auto-create / regenerate .scope.json -----------------------------------
|
|
132
|
+
# CREATION does NOT require the query: if there's a root and no scope yet,
|
|
133
|
+
# scaffold it NOW with intent=<TODO> (the agent fills it from the chat it's
|
|
134
|
+
# already responding to). This was the 0.5.3 bug - creation was gated on
|
|
135
|
+
# $hasQuery, so when Cursor didn't surface transcript_path in the first
|
|
136
|
+
# postToolUse fire, the scope never got created. The agent never had a
|
|
137
|
+
# contract to work from.
|
|
138
|
+
# REGENERATION does require the query: we can only detect a prompt change if
|
|
139
|
+
# we can hash the current request. Without a query we leave an existing scope
|
|
140
|
+
# alone (re-inject it) rather than blank it.
|
|
124
141
|
$regenerated = $false
|
|
125
|
-
$
|
|
126
|
-
|
|
142
|
+
$shouldCreate = -not $scopeExists
|
|
143
|
+
$shouldRegen = $hasQuery -and $scopeExists -and $scopeStale
|
|
144
|
+
if ($shouldCreate -or $shouldRegen) {
|
|
127
145
|
try {
|
|
146
|
+
$intentVal = if ($hasQuery) { $currentQuery } else { '<TODO: state the operational objective - what is strictly necessary>' }
|
|
128
147
|
$scaffold = [ordered]@{
|
|
129
|
-
intent = $
|
|
130
|
-
files = @(
|
|
148
|
+
intent = $intentVal
|
|
149
|
+
files = @()
|
|
131
150
|
acceptance = '<TODO: the one deterministic check that decides done>'
|
|
132
151
|
allow_growth = $false
|
|
133
152
|
_intent_hash = $currentHash
|
|
@@ -135,9 +154,9 @@ if ($shouldWrite) {
|
|
|
135
154
|
}
|
|
136
155
|
$json = $scaffold | ConvertTo-Json -Depth 5
|
|
137
156
|
[System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
|
|
138
|
-
$scopeIntent = $
|
|
157
|
+
$scopeIntent = $intentVal
|
|
139
158
|
$scopeAcceptance = '<TODO: the one deterministic check that decides done>'
|
|
140
|
-
$scopeFiles = '
|
|
159
|
+
$scopeFiles = '(auto-tracked - the scope hook records every file you edit)'
|
|
141
160
|
$scopeExists = $true
|
|
142
161
|
$scopeStale = $false
|
|
143
162
|
$regenerated = $true
|
|
@@ -158,9 +177,10 @@ INTENT ANCHOR (scope regenerated) - .scope.json written for this prompt.
|
|
|
158
177
|
acceptance: $scopeAcceptance
|
|
159
178
|
|
|
160
179
|
The hook wrote a fresh scaffold to $scopePath from your current request. intent
|
|
161
|
-
is locked from what you just asked.
|
|
162
|
-
|
|
163
|
-
|
|
180
|
+
is locked from what you just asked. files[] is AUTO-TRACKED - the scope hook
|
|
181
|
+
records every file you edit, so do not maintain it by hand. Set acceptance to
|
|
182
|
+
the one deterministic check that decides done, THEN proceed. This contract will
|
|
183
|
+
be re-injected every turn until your request changes again.
|
|
164
184
|
"@
|
|
165
185
|
} elseif (-not $scopeExists) {
|
|
166
186
|
$msg = @"
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
# scope-gate-audit.ps1 - afterFileEdit "
|
|
1
|
+
# scope-gate-audit.ps1 - afterFileEdit "scope auto-record" (Cursor).
|
|
2
2
|
#
|
|
3
|
-
# Compuerta 1
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
3
|
+
# Compuerta 1, mechanical edition: keep .scope.json's files[] in sync with what
|
|
4
|
+
# the agent ACTUALLY edits, with ZERO reliance on the model remembering to fill
|
|
5
|
+
# it. intent-anchor.ps1 writes the scaffold (intent locked from the prompt,
|
|
6
|
+
# files: [], acceptance: TODO); THIS hook appends every edited file to files[]
|
|
7
|
+
# as the edit happens. Net effect: the contract's files[] is always an accurate
|
|
8
|
+
# ledger of the session footprint, which final-review audits against intent
|
|
9
|
+
# (the "you touched 8 files for a 1-line request - justify" axis).
|
|
9
10
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
11
|
+
# This REPLACES the old declared-scope VIOLATION advisory. When every edit is
|
|
12
|
+
# auto-recorded, an edit can never be "out of declared scope" - there is nothing
|
|
13
|
+
# to violate. The gate became a recorder. acceptance stays the model's to fill:
|
|
14
|
+
# a deterministic success check cannot be derived mechanically.
|
|
14
15
|
#
|
|
15
|
-
#
|
|
16
|
-
# .
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# Advisory only: never blocks, never persists state, ALWAYS exits 0.
|
|
16
|
+
# Opt-in: silent if .scope.json does not exist in the repo root (no scaffold yet
|
|
17
|
+
# = nothing to maintain). Rewrites ONLY files[]; every other field (intent,
|
|
18
|
+
# acceptance, allow_growth, _intent_hash, _generated_by, ...) is preserved.
|
|
19
|
+
# Never blocks, never needs Python, ALWAYS exits 0.
|
|
20
20
|
# Disable: HOOKS_ENFORCE=0 or SCOPE_GATE_ENFORCE=0
|
|
21
21
|
|
|
22
22
|
$ErrorActionPreference = 'SilentlyContinue'
|
|
@@ -43,94 +43,55 @@ foreach ($k in 'file_path', 'path', 'filename', 'absolute_path', 'abs_path') {
|
|
|
43
43
|
if (-not $fp) { exit 0 }
|
|
44
44
|
$rel = ConvertTo-FwdPath $fp
|
|
45
45
|
if ($rel.StartsWith($root + '/', [System.StringComparison] 'OrdinalIgnoreCase')) { $rel = $rel.Substring($root.Length + 1) }
|
|
46
|
+
$rel = $rel.TrimStart('/')
|
|
46
47
|
if (Test-IsCursorConfigPath $fp) { exit 0 }
|
|
47
48
|
if (Test-IsCursorConfigPath $rel) { exit 0 }
|
|
49
|
+
# Never record the contract file into itself.
|
|
50
|
+
if ($rel -ieq '.scope.json') { exit 0 }
|
|
48
51
|
|
|
49
|
-
# --- opt-in gate: no .scope.json =
|
|
52
|
+
# --- opt-in gate: no .scope.json = nothing to maintain ---------------------
|
|
50
53
|
$scopeFile = "$root/.scope.json"
|
|
51
54
|
if (-not (Test-Path -LiteralPath $scopeFile)) { exit 0 }
|
|
52
55
|
|
|
53
|
-
# ---
|
|
54
|
-
$
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
$
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
$acceptance = ''
|
|
82
|
-
if ($payload.PSObject.Properties['acceptance']) { $acceptance = [string]$payload.acceptance }
|
|
83
|
-
|
|
84
|
-
# Read the declared files list for the message (best-effort; skip on failure)
|
|
85
|
-
$declaredFiles = ''
|
|
86
|
-
try {
|
|
87
|
-
$scopeJson = Get-Content -LiteralPath $scopeFile -Raw | ConvertFrom-Json
|
|
88
|
-
if ($scopeJson.files) { $declaredFiles = ($scopeJson.files -join ', ') }
|
|
89
|
-
} catch { }
|
|
90
|
-
|
|
91
|
-
# acceptance line: only quote it when the agent bothered to declare one. A blank
|
|
92
|
-
# acceptance means the Anchor Set was incomplete - surface that gap, since the
|
|
93
|
-
# whole point of the pre-compile phase is to name the deterministic success check.
|
|
94
|
-
$acceptanceLine = if ($acceptance) { $acceptance } else { '(not declared — your Anchor Set is missing the ÉXITO/acceptance field)' }
|
|
95
|
-
|
|
96
|
-
if ($allowGrowth) {
|
|
97
|
-
# Growth is allowed: informational, not a violation
|
|
98
|
-
$summary = "Scope note - $rel is new vs your declared scope (growth allowed)"
|
|
99
|
-
$body = @"
|
|
100
|
-
You touched a file outside your initial declared set. Since allow_growth is
|
|
101
|
-
true, this is not a violation, but justify it: add $rel to .scope.json or
|
|
102
|
-
explain why the scope grew.
|
|
103
|
-
|
|
104
|
-
Your success contract (acceptance): $acceptanceLine
|
|
105
|
-
Does growing into $rel still serve that?
|
|
106
|
-
"@
|
|
107
|
-
} else {
|
|
108
|
-
# Hard violation: edited outside the declared contract
|
|
109
|
-
$summary = "[SCOPE VIOLATION] $rel is NOT in your declared scope"
|
|
110
|
-
$body = @"
|
|
111
|
-
Your contract (.scope.json):
|
|
112
|
-
intent: $intent
|
|
113
|
-
files: $declaredFiles
|
|
114
|
-
acceptance: $acceptanceLine
|
|
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.
|
|
56
|
+
# --- load the contract; bail quietly on malformed JSON ---------------------
|
|
57
|
+
$sj = $null
|
|
58
|
+
try { $sj = Get-Content -LiteralPath $scopeFile -Raw | ConvertFrom-Json } catch { exit 0 }
|
|
59
|
+
if (-not $sj) { exit 0 }
|
|
60
|
+
|
|
61
|
+
# --- compute the new files[] -----------------------------------------------
|
|
62
|
+
# Start from existing files, drop the scaffold placeholder and blanks, then add
|
|
63
|
+
# this edit if it is not already recorded (case-insensitive, slash-normalized).
|
|
64
|
+
$existing = @()
|
|
65
|
+
if ($sj.PSObject.Properties['files'] -and $sj.files) { $existing = @($sj.files) }
|
|
66
|
+
|
|
67
|
+
$kept = @()
|
|
68
|
+
foreach ($e in $existing) {
|
|
69
|
+
if (-not $e) { continue }
|
|
70
|
+
$s = [string]$e
|
|
71
|
+
if ($s -match '^\s*<TODO') { continue } # drop the scaffold placeholder
|
|
72
|
+
if ([string]::IsNullOrWhiteSpace($s)) { continue }
|
|
73
|
+
$kept += $s
|
|
74
|
+
}
|
|
119
75
|
|
|
120
|
-
|
|
121
|
-
|
|
76
|
+
$already = $false
|
|
77
|
+
foreach ($f in $kept) {
|
|
78
|
+
if (([string]$f).Replace('\', '/').TrimStart('/') -ieq $rel) { $already = $true; break }
|
|
122
79
|
}
|
|
80
|
+
if (-not $already) { $kept += $rel }
|
|
123
81
|
|
|
124
|
-
|
|
82
|
+
# Only rewrite when files[] actually changed (avoid churning the file on every
|
|
83
|
+
# repeat edit of the same path).
|
|
84
|
+
$before = ($existing | ForEach-Object { [string]$_ }) -join '|'
|
|
85
|
+
$after = ($kept | ForEach-Object { [string]$_ }) -join '|'
|
|
86
|
+
if ($before -eq $after) { exit 0 }
|
|
125
87
|
|
|
126
|
-
# ---
|
|
127
|
-
$cid = Get-SafeConversationId $obj
|
|
128
|
-
$pending = Join-Path (Get-HooksPendingDir) "feedback-$cid.txt"
|
|
88
|
+
# --- write back, preserving every other field and its order ----------------
|
|
129
89
|
try {
|
|
130
|
-
|
|
131
|
-
$
|
|
132
|
-
|
|
133
|
-
|
|
90
|
+
$ordered = [ordered]@{}
|
|
91
|
+
foreach ($p in $sj.PSObject.Properties) { $ordered[$p.Name] = $p.Value }
|
|
92
|
+
$ordered['files'] = @($kept) # force array form under pwsh 7
|
|
93
|
+
$json = $ordered | ConvertTo-Json -Depth 8
|
|
94
|
+
[System.IO.File]::WriteAllText($scopeFile, $json, [System.Text.UTF8Encoding]::new($false))
|
|
134
95
|
} catch { }
|
|
135
96
|
|
|
136
97
|
exit 0
|
package/windows/hooks.json
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"command": "pwsh.exe -NoProfile -File ~/.agents/hooks/scope-gate-audit.ps1",
|
|
26
26
|
"timeout": 10,
|
|
27
27
|
"matcher": "^(Write|StrReplace|EditNotebook)$",
|
|
28
|
-
"_comment": "10s (Compuerta 1):
|
|
28
|
+
"_comment": "10s (Compuerta 1, mechanical): scope auto-record. OPT-IN: only active when .scope.json exists in the repo root. intent-anchor scaffolds files:[]; this hook APPENDS every edited file to files[] (drops the scaffold placeholder, dedups, preserves all other fields), so files[] is always an accurate ledger of the session footprint that final-review audits against intent. No model effort required - the agent never maintains files[] by hand. Replaces the old [SCOPE VIOLATION] advisory (with auto-record an edit can never be out-of-declared-scope). No JSON tool / no .scope.json = silent. Never blocks, always exits 0. Disable: HOOKS_ENFORCE=0 or SCOPE_GATE_ENFORCE=0."
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
"command": "pwsh.exe -NoProfile -File ~/.agents/hooks/anti-slop-audit.ps1",
|