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 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/acceptance as TODO placeholders the agent
22
- # refines. This is the user-requested behavior: every new prompt ->
23
- # a fresh .scope.json the agent works from. Fixed vs the broken 0.4.4
24
- # build: never writes to $HOME (bails if no real root resolves -> no
25
- # ghost files), and regenerates on prompt CHANGE not just on absence.
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 (the 0.4.4 behavior, fixed) --------
135
- # The user wants: every NEW prompt -> a fresh .scope.json the agent works from.
136
- # So we WRITE the scaffold when (a) there is no valid contract, OR (b) the
137
- # contract on disk is stale (its _intent_hash != current query hash). Fixed vs
138
- # 0.4.4: never writes to $HOME (bail above if no real root) -> no ghost files;
139
- # regenerates on prompt CHANGE not just absence -> "each prompt, new file".
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
- should_write=0
142
- if [ "$has_query" = "1" ]; then
143
- if [ "$scope_exists" != "1" ] || [ "$scope_stale" = "1" ]; then
144
- should_write=1
145
- fi
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 [ "$should_write" = "1" ]; then
149
- # jq preferred; python3 fallback. Write intent from the query, TODO
150
- # placeholders for files/acceptance, and record _intent_hash so staleness
151
- # is self-contained in the file (survives cross-session hash sweeps).
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 "$current_query" --arg hash "$current_hash" \
154
- '{intent:$intent, files:["<TODO: list files you will touch>"], acceptance:"<TODO: the one deterministic check that decides done>", allow_growth:false, _intent_hash:$hash, _generated_by:"intent-anchor hook"}' \
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="$current_query" I_HASH="$current_hash" python3 -c '
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": ["<TODO: list files you will touch>"],
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="$current_query"
194
+ scope_intent="$intent_val"
175
195
  scope_acceptance="<TODO: the one deterministic check that decides done>"
176
- scope_files="<TODO: list files you will touch>"
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. Fill the TODO placeholders with the real
200
- files you will touch and the deterministic acceptance check, THEN proceed. This
201
- contract will be re-injected every turn until your request changes again."
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 "declared scope" advisory (Cursor, Linux).
2
+ # scope-gate-audit.sh - afterFileEdit "scope auto-record" (Cursor, Linux).
3
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.
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
- # 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).
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
- # 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.
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 = no gate ---------------------------------
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
- # --- 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
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
- p = json.loads(sys.stdin.read())
76
+ d = json.load(open(path, encoding="utf-8"))
73
77
  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
- acceptance = p.get("acceptance", "")
82
- print(f"__AG__{allow_growth}")
83
- print(f"__INTENT__{intent}")
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): 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."
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",
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 / REGENERATE .scope.json: when the current <user_query>
18
- # differs from the contract on disk (no contract yet, OR _intent_hash
19
- # mismatch), the hook WRITES a scaffold to the REPO ROOT: intent locked
20
- # from the prompt, files/acceptance as TODO placeholders the agent
21
- # refines. This is the user-requested behavior: every new prompt ->
22
- # a fresh .scope.json the agent works from. Fixed vs the broken 0.4.4
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), and regenerates on prompt CHANGE not just on absence.
25
- # 3. RE-INJECT on same-prompt turns: when the query is unchanged (contract
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 (the 0.4.4 behavior, fixed) --------
116
- # The user wants: every NEW prompt -> a fresh .scope.json the agent works from.
117
- # So we WRITE the scaffold when (a) there is no valid contract, OR (b) the
118
- # contract on disk is stale (its _intent_hash != current query hash). Intent is
119
- # locked from the current <user_query>; files/acceptance are TODO placeholders
120
- # the agent refines. Fixed vs 0.4.4:
121
- # - NEVER writes to $HOME (bail above if no real root) -> no ghost files.
122
- # - Regenerates on prompt CHANGE, not just on absence -> "each prompt, new file".
123
- # - Records _intent_hash so staleness is self-contained in the file.
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
- $shouldWrite = $hasQuery -and (-not $scopeExists -or $scopeStale)
126
- if ($shouldWrite) {
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 = $currentQuery
130
- files = @('<TODO: list files you will touch>')
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 = $currentQuery
157
+ $scopeIntent = $intentVal
139
158
  $scopeAcceptance = '<TODO: the one deterministic check that decides done>'
140
- $scopeFiles = '<TODO: list files you will touch>'
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. Fill the TODO placeholders with the real
162
- files you will touch and the deterministic acceptance check, THEN proceed. This
163
- contract will be re-injected every turn until your request changes again.
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 "declared scope" advisory (Cursor).
1
+ # scope-gate-audit.ps1 - afterFileEdit "scope auto-record" (Cursor).
2
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.
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
- # 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).
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
- # 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.
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 = no gate ---------------------------------
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
- # --- 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
- $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
- Declared-editing: declare BEFORE you expand. Don't sneak edits past the gate.
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
- $msg = "$summary`n`n$body`n`n(Advisory; disable: SCOPE_GATE_ENFORCE=0)"
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
- # --- append to the shared pending file --------------------------------------
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
- New-Item -ItemType Directory -Path (Split-Path $pending) -Force | Out-Null
131
- $prefix = ''
132
- if ((Test-Path $pending) -and ((Get-Item $pending).Length -gt 0)) { $prefix = "`n`n---`n`n" }
133
- Add-Content -Path $pending -Value ($prefix + $msg) -NoNewline
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
@@ -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): 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."
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",