cursordoctrine 0.5.2 → 0.5.4

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
@@ -62,6 +62,29 @@ function keyOf(command, keys) {
62
62
  return keys.find((k) => command.includes(k));
63
63
  }
64
64
 
65
+ // Detect a STALE entry from a prior install: its command names a hook script
66
+ // (ps1/sh, not an .md prompt) under .agents/hooks OR names inject-doctrine,
67
+ // but that filename is NOT in the current payload. These are NOT foreign
68
+ // entries the user added — they are leftovers from an older version of THIS
69
+ // pack (e.g. anchor-set-nudge / minimal-edit-audit, deleted in 0.5.0). The
70
+ // old merge preserved them as "foreign", so deleted hooks kept running on
71
+ // every edit silently. Returning true here makes install reap them.
72
+ const HOOK_FILENAME_RE = /([\w.\-]+)\.(ps1|sh)\b/;
73
+ const INJECT_RE = /\binject-doctrine\.(ps1|sh)\b/;
74
+ function isStaleOurs(command) {
75
+ if (typeof command !== 'string') return false;
76
+ const hookMatch = command.match(HOOK_FILENAME_RE);
77
+ if (hookMatch) {
78
+ const fname = `${hookMatch[1]}.${hookMatch[2]}`;
79
+ // If the script still ships, it's current ours (handled elsewhere).
80
+ return !payloadHookFiles().includes(fname);
81
+ }
82
+ if (INJECT_RE.test(command)) {
83
+ return !doctrineFiles.includes(injectName) ? true : false;
84
+ }
85
+ return false;
86
+ }
87
+
65
88
  function mergeHooks(existing, incoming, keys) {
66
89
  const out = structuredClone(existing);
67
90
  if (out.version === undefined) out.version = incoming.version;
@@ -75,22 +98,25 @@ function mergeHooks(existing, incoming, keys) {
75
98
  if (i >= 0) cur[i] = entry;
76
99
  else cur.push(entry);
77
100
  }
78
- // Re-order our entries to match the shipped hooks.json (merge used to leave
79
- // stale order e.g. post-tool-use before intent-anchor breaking same-tool
80
- // delivery of the anchor message).
81
- const foreign = cur.filter((x) => x && !isOurs(x.command, keys));
101
+ // Reap stale ours entries from prior installs BEFORE treating anything as
102
+ // foreign. isStaleOurs catches commands referencing scripts this pack no
103
+ // longer ships (e.g. anchor-set-nudge / minimal-edit-audit, deleted in
104
+ // 0.5.0). Without this, deleted hooks kept running silently on every edit
105
+ // because the merge mistook them for user-added foreign entries.
106
+ const live = cur.filter((x) => x && !isStaleOurs(x.command));
107
+ const foreign = live.filter((x) => x && !isOurs(x.command, keys));
82
108
  const reordered = [];
83
109
  const used = new Set();
84
110
  for (const entry of entries) {
85
111
  const k = keyOf(entry.command, keys);
86
112
  if (!k || !isOurs(entry.command, keys)) continue;
87
- const found = cur.find((x) => x && keyOf(x.command, keys) === k);
113
+ const found = live.find((x) => x && keyOf(x.command, keys) === k);
88
114
  if (found) {
89
115
  reordered.push(found);
90
116
  used.add(k);
91
117
  }
92
118
  }
93
- for (const x of cur) {
119
+ for (const x of live) {
94
120
  const k = keyOf(x?.command, keys);
95
121
  if (isOurs(x?.command, keys) && k && !used.has(k)) reordered.push(x);
96
122
  }
@@ -429,7 +455,12 @@ function uninstall() {
429
455
  let foreign = 0;
430
456
  for (const [event, entries] of Object.entries(existing.hooks || {})) {
431
457
  if (!Array.isArray(entries)) continue;
432
- existing.hooks[event] = entries.filter((x) => !isOurs(x?.command, keys));
458
+ // Remove both current ours AND stale ours (commands referencing scripts
459
+ // this pack no longer ships - leftovers from prior versions). Pure
460
+ // foreign entries are kept, same as before.
461
+ existing.hooks[event] = entries.filter(
462
+ (x) => !x || (!isOurs(x.command, keys) && !isStaleOurs(x.command))
463
+ );
433
464
  foreign += existing.hooks[event].length;
434
465
  if (existing.hooks[event].length === 0) delete existing.hooks[event];
435
466
  }
@@ -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.
@@ -151,14 +153,14 @@ if [ "$should_write" = "1" ]; then
151
153
  # is self-contained in the file (survives cross-session hash sweeps).
152
154
  if have_jq; then
153
155
  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"}' \
156
+ '{intent:$intent, files:[], acceptance:"<TODO: the one deterministic check that decides done>", allow_growth:false, _intent_hash:$hash, _generated_by:"intent-anchor hook"}' \
155
157
  > "$scope_path" 2>/dev/null && regenerated=1
156
158
  elif have_py; then
157
159
  if I_FILE="$scope_path" I_INTENT="$current_query" I_HASH="$current_hash" python3 -c '
158
160
  import json, os
159
161
  obj = {
160
162
  "intent": os.environ["I_INTENT"],
161
- "files": ["<TODO: list files you will touch>"],
163
+ "files": [],
162
164
  "acceptance": "<TODO: the one deterministic check that decides done>",
163
165
  "allow_growth": False,
164
166
  "_intent_hash": os.environ["I_HASH"],
@@ -173,12 +175,16 @@ with open(os.environ["I_FILE"], "w", encoding="utf-8") as f:
173
175
  if [ "$regenerated" = "1" ]; then
174
176
  scope_intent="$current_query"
175
177
  scope_acceptance="<TODO: the one deterministic check that decides done>"
176
- scope_files="<TODO: list files you will touch>"
178
+ scope_files="(auto-tracked - the scope hook records every file you edit)"
177
179
  scope_exists=1
178
180
  scope_stale=0
179
181
  fi
180
182
  fi
181
183
 
184
+ # files[] is auto-tracked and starts empty; show something readable until the
185
+ # scope hook has recorded the first edit.
186
+ [ -n "$scope_files" ] || scope_files="(none yet - auto-tracked as you edit)"
187
+
182
188
  # --- compose the anchor message ---------------------------------------------
183
189
  # Three states: regenerated this turn (new prompt), no contract (and no query
184
190
  # to scaffold from), or re-injecting an existing current contract.
@@ -196,9 +202,10 @@ if [ "$regenerated" = "1" ]; then
196
202
  acceptance: $scope_acceptance
197
203
 
198
204
  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."
205
+ is locked from what you just asked. files[] is AUTO-TRACKED - the scope hook
206
+ records every file you edit, so do not maintain it by hand. Set acceptance to
207
+ the one deterministic check that decides done, THEN proceed. This contract will
208
+ be re-injected every turn until your request changes again."
202
209
  elif [ "$scope_exists" != "1" ]; then
203
210
  msg="INTENT ANCHOR (pre-compile) - no .scope.json found in $root, and the current
204
211
  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.2",
3
+ "version": "0.5.4",
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"
@@ -17,11 +17,13 @@
17
17
  # 2. AUTO-CREATE / REGENERATE .scope.json: when the current <user_query>
18
18
  # differs from the contract on disk (no contract yet, OR _intent_hash
19
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
23
- # build: never writes to $HOME (bails if no real root resolves -> no
24
- # ghost files), and regenerates on prompt CHANGE not just on absence.
20
+ # from the prompt, files as an EMPTY array (scope-gate-audit.ps1 fills it
21
+ # mechanically as the agent edits - the agent never maintains files[] by
22
+ # hand), acceptance as a TODO the agent sets. This is the user-requested
23
+ # behavior: every new prompt -> a fresh .scope.json the agent works from.
24
+ # Fixed vs the broken 0.4.4 build: never writes to $HOME (bails if no real
25
+ # root resolves -> no ghost files), regenerates on prompt CHANGE not just
26
+ # on absence.
25
27
  # 3. RE-INJECT on same-prompt turns: when the query is unchanged (contract
26
28
  # already current), the hook re-injects the existing contract into the
27
29
  # feedback bus so it stays in the model's attentional focus each turn.
@@ -101,7 +103,8 @@ if (Test-Path -LiteralPath $scopePath) {
101
103
  $sj = Get-Content -LiteralPath $scopePath -Raw | ConvertFrom-Json
102
104
  if ($sj.intent) { $scopeIntent = [string]$sj.intent }
103
105
  if ($sj.acceptance) { $scopeAcceptance = [string]$sj.acceptance }
104
- if ($sj.files) { $scopeFiles = ($sj.files -join ', ') }
106
+ if ($sj.files) { $scopeFiles = (@($sj.files) -join ', ') }
107
+ if ([string]::IsNullOrWhiteSpace($scopeFiles)) { $scopeFiles = '(none yet - auto-tracked as you edit)' }
105
108
  $scopeExists = $true
106
109
  # The contract is "stale" if its recorded intent hash != current query
107
110
  # hash. We persist the query hash inside .scope.json under _intent_hash
@@ -116,8 +119,8 @@ if (Test-Path -LiteralPath $scopePath) {
116
119
  # The user wants: every NEW prompt -> a fresh .scope.json the agent works from.
117
120
  # So we WRITE the scaffold when (a) there is no valid contract, OR (b) the
118
121
  # 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:
122
+ # locked from the current <user_query>; files starts EMPTY (scope-gate-audit
123
+ # auto-records edits into it); acceptance is a TODO the agent sets. Fixed vs 0.4.4:
121
124
  # - NEVER writes to $HOME (bail above if no real root) -> no ghost files.
122
125
  # - Regenerates on prompt CHANGE, not just on absence -> "each prompt, new file".
123
126
  # - Records _intent_hash so staleness is self-contained in the file.
@@ -127,7 +130,7 @@ if ($shouldWrite) {
127
130
  try {
128
131
  $scaffold = [ordered]@{
129
132
  intent = $currentQuery
130
- files = @('<TODO: list files you will touch>')
133
+ files = @()
131
134
  acceptance = '<TODO: the one deterministic check that decides done>'
132
135
  allow_growth = $false
133
136
  _intent_hash = $currentHash
@@ -137,7 +140,7 @@ if ($shouldWrite) {
137
140
  [System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
138
141
  $scopeIntent = $currentQuery
139
142
  $scopeAcceptance = '<TODO: the one deterministic check that decides done>'
140
- $scopeFiles = '<TODO: list files you will touch>'
143
+ $scopeFiles = '(auto-tracked - the scope hook records every file you edit)'
141
144
  $scopeExists = $true
142
145
  $scopeStale = $false
143
146
  $regenerated = $true
@@ -158,9 +161,10 @@ INTENT ANCHOR (scope regenerated) - .scope.json written for this prompt.
158
161
  acceptance: $scopeAcceptance
159
162
 
160
163
  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.
164
+ is locked from what you just asked. files[] is AUTO-TRACKED - the scope hook
165
+ records every file you edit, so do not maintain it by hand. Set acceptance to
166
+ the one deterministic check that decides done, THEN proceed. This contract will
167
+ be re-injected every turn until your request changes again.
164
168
  "@
165
169
  } elseif (-not $scopeExists) {
166
170
  $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",