cursordoctrine 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/INSTALL.md CHANGED
@@ -120,4 +120,4 @@ Also validate the config: `~/.cursor/hooks.json` must parse as JSON.
120
120
 
121
121
  Tell the user what was installed, which checks passed, and anything that failed with the exact error. Do not silently work around a failing check.
122
122
 
123
- Kill switches if something misbehaves: `HOOKS_ENFORCE=0` (everything advisory off), `PERM_GATE_ENFORCE=0`, `ANCHOR_NUDGE_ENFORCE=0` (pre-compile nudge off), `MINIMAL_EDITING_ENFORCE=0` (deprecated in 0.3.0), `SEMANTIC_DENSITY_ENFORCE=0`, `SCOPE_GATE_ENFORCE=0`, `ANTI_SLOP_ENFORCE=0`, `FINAL_REVIEW_ENFORCE=0`, `SUBAGENT_REVIEW_ENFORCE=0`.
123
+ Kill switches if something misbehaves: `HOOKS_ENFORCE=0` (everything advisory off), `PERM_GATE_ENFORCE=0`, `ANCHOR_NUDGE_ENFORCE=0` (pre-compile nudge off), `INTENT_ANCHOR_ENFORCE=0` (thin-intent re-injection off), `MINIMAL_EDITING_ENFORCE=0` (deprecated in 0.3.0), `SEMANTIC_DENSITY_ENFORCE=0`, `SCOPE_GATE_ENFORCE=0`, `ANTI_SLOP_ENFORCE=0`, `FINAL_REVIEW_ENFORCE=0`, `SUBAGENT_REVIEW_ENFORCE=0`.
package/README.md CHANGED
@@ -94,12 +94,23 @@ On the **first edit of each agent turn**, `anchor-set-nudge` drops a reminder in
94
94
 
95
95
  The Anchor Set is skipped for trivial one-liners (typo, literal) — the `declared-editing.md` ladder's rung 1 governs when it's overkill.
96
96
 
97
+ ### Keeping the contract alive: `intent-anchor` (anti-Salience-Dilution)
98
+
99
+ Writing `.scope.json` once is not enough. As a conversation fills with code, logs and errors, the token of the original request shrinks to a rounding error against the recent history — *Salience Dilution* — and the agent stops checking the contract it wrote at prompt 1. It forgets symmetry, colors, the acceptance bar. This is the failure mode the nudge alone can't fix (a reminder that the contract exists ≠ the contract being in context).
100
+
101
+ `intent-anchor` (`postToolUse`, registered first so it runs before `post-tool-use` drains the bus) does two things on the **first tool boundary of every turn** (per-turn latch, cleared unconditionally at each stop):
102
+
103
+ 1. **Re-inject the contract.** Reads `.scope.json` and stashes `intent` + `files` + `acceptance` into the feedback bus, which `post-tool-use` delivers as `additional_context`. The contract is back in the model's attentional focus at the start of each turn's work — **before** edits pile up and dilute it. This runs unconditionally (no transcript needed); it's the core anti-dilution move.
104
+ 2. **Re-compile on prompt change.** Hashes the current `<user_query>` and compares to the previous turn's hash (`last-query-<cid>.hash`, which persists across turns). If the request moved, it demands the agent **update** `.scope.json` to match — the scope tracks the request. If no `.scope.json` exists, it demands one be written. (Needs `transcript_path` in the payload; if absent this part degrades to silent but the re-injection still runs.)
105
+
106
+ Crucially, `intent-anchor` carries the **semantic** contract (`intent`/`acceptance`) into context every turn — something the path-only `scope-gate-audit` can never do. That is what makes "the agent forgot about grid symmetry while editing the right file" catchable: the symmetry requirement is re-stated in front of the model before each edit, not just checked against a file list after.
107
+
97
108
  ## The five flows
98
109
 
99
110
  | Flow | Event | What happens |
100
111
  |---|---|---|
101
112
  | Session | `sessionStart` | `inject-doctrine` reads doctrine + user rules + declared-editing + **pre-compile** and emits them as `additional_context`. |
102
- | Every turn | `postToolUse` | Folds completed subagents' edit markers into this conversation's marker, then drains the conversation's pending feedback file into `additional_context`. One-shot, keyed by conversation id. |
113
+ | Every turn | `postToolUse` | **`intent-anchor`** (registered first) re-injects `.scope.json` into `additional_context` at the first tool boundary of each turn — the anti-Salience-Dilution move that keeps `intent` + `acceptance` in the model's attentional focus before edits pile up. If the prompt changed since last turn, it demands the contract be updated. Then `post-tool-use` folds subagent markers and drains the feedback file. |
103
114
  | Shell | `beforeShellExecution` | `permission-gate` checks the command against a deny list. Allow by default, deny by list, fail open. |
104
115
  | Edit | `afterFileEdit` + `stop` | **Proactive:** `anchor-set-nudge` fires once per turn (on its first edit) to push the Anchor Set. **Reactive:** `self-review-trigger` stashes the review prompt per edit; `minimal-edit-audit` (deprecated), `semantic-density-audit`, `scope-gate-audit` (opt-in, audits `.scope.json`), and `anti-slop-audit` append advisories when they trip; `final-review` fires one end-of-implementation six-axis pass. |
105
116
  | Subagent | `subagentStop` | `subagent-stop-review` fires one in-subagent final review when a delegated run edited files, before the result returns to the parent. Marker-gated and flag-braked like `final-review`. |
@@ -137,6 +148,7 @@ All hooks fail open and always exit 0. Nothing here can block your session.
137
148
  | `HOOKS_ENFORCE=0` | on | turns off all advisory hooks at once |
138
149
  | `PERM_GATE_ENFORCE=0` | on | disables the permission gate |
139
150
  | `ANCHOR_NUDGE_ENFORCE=0` | on | disables the pre-compile nudge (first-edit Anchor Set reminder) |
151
+ | `INTENT_ANCHOR_ENFORCE=0` | on | disables the thin-intent re-injection (per-turn `.scope.json` echo into `additional_context`) |
140
152
  | `MINIMAL_EDITING_ENFORCE=0` | on | disables the over-edit advisory (deprecated in 0.3.0) |
141
153
  | `SCOPE_GATE_ENFORCE=0` | on | disables the declared-scope advisory (opt-in: only fires when `.scope.json` exists) |
142
154
  | `SEMANTIC_DENSITY_ENFORCE=0` | on | disables the semantic-opacity advisory |
package/bin/cli.mjs CHANGED
@@ -300,6 +300,50 @@ function verify() {
300
300
  return true;
301
301
  });
302
302
 
303
+ check('intent-anchor re-injects .scope.json every turn; latch re-arms at stop', () => {
304
+ // intent-anchor appends to feedback-<cid>.txt (the shared bus); drain via
305
+ // post-tool-use the same way the harness delivers additional_context.
306
+ const drainedOf = (cidv) => runHook(hook('post-tool-use'), { conversation_id: cidv });
307
+ const anchorCid = 'npxv4';
308
+ const scopePath = join(HOME, '.scope.json');
309
+
310
+ const cleanup = () => { try { rmSync(scopePath, { force: true }); } catch {} };
311
+ cleanup();
312
+
313
+ // --- Case A: no .scope.json -> demand one be written --------------
314
+ runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: HOME });
315
+ let d = drainedOf(anchorCid);
316
+ if (!d.includes('INTENT ANCHOR') || !d.includes('NOT compiled your Anchor Set')) {
317
+ cleanup(); return { ok: false, detail: 'no-scope branch did not demand compilation' };
318
+ }
319
+
320
+ // --- Stop clears the latch (the regression path from 0.4.1) -------
321
+ runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
322
+
323
+ // --- Case B: scope exists -> re-inject contract every turn --------
324
+ writeFileSync(scopePath, JSON.stringify({
325
+ intent: 'fix grid symmetry and color tokens',
326
+ files: ['src/grid.tsx'],
327
+ acceptance: 'grid renders symmetric; tokens match palette',
328
+ }));
329
+ // Turn 2 (no transcript in sandbox -> query unavailable -> re-inject branch).
330
+ runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: HOME });
331
+ d = drainedOf(anchorCid);
332
+ if (!d.includes('fix grid symmetry and color tokens') || !d.includes('INTENT ANCHOR')) {
333
+ cleanup(); return { ok: false, detail: 'contract not re-injected on turn 2' };
334
+ }
335
+
336
+ // --- Stop clears the latch again; turn 3 must re-inject too -------
337
+ runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
338
+ runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: HOME });
339
+ d = drainedOf(anchorCid);
340
+ if (!d.includes('fix grid symmetry and color tokens')) {
341
+ cleanup(); return { ok: false, detail: 'contract not re-injected on turn 3 (latch stranded at stop)' };
342
+ }
343
+ cleanup();
344
+ return true;
345
+ });
346
+
303
347
  check('doctrine injection emits additional_context', () =>
304
348
  runHook(join(cursorDst, injectName), {}).includes('additional_context'));
305
349
 
@@ -410,6 +454,7 @@ Kill switches (environment variables, all hooks fail open)
410
454
  HOOKS_ENFORCE=0 everything advisory off
411
455
  PERM_GATE_ENFORCE=0 permission gate off
412
456
  ANCHOR_NUDGE_ENFORCE=0 pre-compile nudge off (first-edit Anchor Set reminder)
457
+ INTENT_ANCHOR_ENFORCE=0 thin-intent re-injection off (per-turn .scope.json echo)
413
458
  MINIMAL_EDITING_ENFORCE=0 over-edit advisory off (deprecated in 0.3.0)
414
459
  SEMANTIC_DENSITY_ENFORCE=0 semantic-opacity advisory off
415
460
  SCOPE_GATE_ENFORCE=0 declared-scope advisory off
@@ -37,16 +37,20 @@ pending_dir="$(hooks_pending_dir)"
37
37
  marker="$pending_dir/session-edits-$cid.txt"
38
38
  flag="$pending_dir/reviewed-$cid.flag"
39
39
  anchor_flag="$pending_dir/anchor-declared-$cid.flag"
40
+ intent_latch="$pending_dir/intent-injected-$cid.flag"
40
41
 
41
42
  # Sweep state from sessions that died before their stop hook ran.
42
43
  find "$pending_dir" -maxdepth 1 -type f -mtime +7 -delete 2>/dev/null
43
44
 
44
- # Unconditionally clear the pre-compile nudge's per-turn latch. Every stop is a
45
- # turn boundary; clearing here (not only inside the reviewed-flag block below)
46
- # guarantees the nudge re-fires on the first edit of the NEXT turn and can never
47
- # get stranded silenced. Clearing it only on the SECOND stop left the nudge
48
- # permanently off for any conversation that never cleanly hit that boundary.
49
- rm -f "$anchor_flag" 2>/dev/null
45
+ # Unconditionally clear the per-turn latches so the next turn re-fires. Every
46
+ # stop is a turn boundary; clearing here (not only inside the reviewed-flag
47
+ # block below) guarantees these re-fire on the first edit/tool of the NEXT
48
+ # turn and can never get stranded silenced mid-session:
49
+ # - anchor-declared-<cid>.flag (anchor-set-nudge, first-edit reminder)
50
+ # - intent-injected-<cid>.flag (intent-anchor, first-tool re-injection)
51
+ # last-query-<cid>.hash is NOT cleared here - it persists turn-to-turn so
52
+ # intent-anchor can detect prompt changes; the 7-day sweep above reaps it.
53
+ rm -f "$anchor_flag" "$intent_latch" 2>/dev/null
50
54
 
51
55
  # One-shot brake: the previous stop for this conversation emitted the review.
52
56
  if [ -f "$flag" ]; then
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env bash
2
+ # intent-anchor.sh - postToolUse "thin intent compilation" anchor (Cursor, Linux).
3
+ #
4
+ # Counteracts Salience Dilution: the failure mode where the agent's original
5
+ # intent erodes as the conversation fills with code, logs and errors, until the
6
+ # token of the original request is a rounding error against the recent history
7
+ # and the agent drifts ("forgets" symmetry, colors, the .scope.json it wrote at
8
+ # prompt 1). Two jobs, both on the FIRST tool boundary of each turn (per-turn
9
+ # latch intent-injected-<cid>.flag, armed here, cleared at every stop):
10
+ #
11
+ # 1. RE-INJECT .scope.json (the core anti-dilution move): read the contract
12
+ # (intent + files + acceptance) and stash it in the feedback bus so
13
+ # post-tool-use.sh delivers it as additional_context at the next tool
14
+ # boundary. This puts the contract back in the model's attentional focus
15
+ # at the START of each turn's work, before edits pile up and dilute the
16
+ # original intent. Works UNCONDITIONALLY - no transcript needed.
17
+ #
18
+ # 2. RE-COMPILE ON PROMPT CHANGE: hash the current <user_query> (via
19
+ # extract_last_user_query, which reads the transcript) and compare to
20
+ # last-query-<cid>.hash. If they differ, demand the agent UPDATE
21
+ # .scope.json to match the new request. If no .scope.json exists, demand
22
+ # one be written. The scope tracks the request - when the request moves,
23
+ # the scope moves with it. This part needs transcript_path in the payload;
24
+ # if it is absent the hook degrades to silent on change-detection but the
25
+ # re-injection above still runs.
26
+ #
27
+ # Why postToolUse, not afterFileEdit: afterFileEdit only fires AFTER an edit
28
+ # exists, and Cursor has no preToolUse for file edits. postToolUse fires after
29
+ # EVERY tool (Read/Glob/Bash/Write/...), so its first fire of a turn is the
30
+ # earliest moment the agent has begun working - typically right after the first
31
+ # Read/Glob, before any edit. Best available injection point for "before files".
32
+ #
33
+ # Once per turn: latch armed on first fire, cleared UNCONDITIONALLY at every
34
+ # stop (final-review.sh). Cannot strand silenced mid-session. Registered first
35
+ # in the postToolUse array so it appends to the feedback bus before
36
+ # post-tool-use.sh drains it (same-tool delivery; if reordered, delivery slips
37
+ # one tool - still correct).
38
+ #
39
+ # Advisory only: never blocks, never reads the diff, ALWAYS exits 0. Appends to
40
+ # the shared feedback-<cid>.txt bus. Disable: HOOKS_ENFORCE=0 or INTENT_ANCHOR_ENFORCE=0.
41
+
42
+ set +e
43
+ . "$(dirname "$0")/hook-common.sh"
44
+
45
+ [ "${HOOKS_ENFORCE:-}" = "0" ] && exit 0
46
+ [ "${INTENT_ANCHOR_ENFORCE:-}" = "0" ] && exit 0
47
+
48
+ input="$(read_hook_stdin)"
49
+ [ -n "$input" ] || exit 0
50
+
51
+ cid="$(safe_conversation_id "$input")"
52
+ pending_dir="$(hooks_pending_dir)"
53
+ latch="$pending_dir/intent-injected-$cid.flag"
54
+ hash_file="$pending_dir/last-query-$cid.hash"
55
+
56
+ # Already injected this turn -> quiet. Latch cleared at every stop.
57
+ [ -f "$latch" ] && exit 0
58
+
59
+ # --- current request (best-effort; absent in sandboxed runs) -----------------
60
+ current_query="$(extract_last_user_query "$input")"
61
+ has_query=0
62
+ [ -n "$current_query" ] && has_query=1
63
+
64
+ current_hash=""
65
+ prompt_changed=0
66
+ if [ "$has_query" = "1" ]; then
67
+ if command -v sha256sum >/dev/null 2>&1; then
68
+ current_hash="$(printf '%s' "$current_query" | sha256sum | awk '{print $1}')"
69
+ elif command -v shasum >/dev/null 2>&1; then
70
+ current_hash="$(printf '%s' "$current_query" | shasum -a 256 | awk '{print $1}')"
71
+ fi
72
+ prev_hash=""
73
+ [ -f "$hash_file" ] && prev_hash="$(cat "$hash_file" 2>/dev/null)"
74
+ [ "$current_hash" != "$prev_hash" ] && prompt_changed=1
75
+ fi
76
+
77
+ # --- repo root (same resolution as scope-gate-audit.sh) ----------------------
78
+ root=""
79
+ while IFS= read -r cand; do
80
+ [ -n "$cand" ] && [ -d "$cand" ] && { root="${cand%/}"; break; }
81
+ done <<EOF
82
+ $(json_get "$input" cwd)
83
+ $(json_get_array "$input" workspace_roots)
84
+ EOF
85
+ [ -n "$root" ] || root="${CURSOR_PROJECT_DIR:-$HOME}"
86
+ root="${root%/}"
87
+
88
+ # --- read the existing contract (if any) -------------------------------------
89
+ scope_exists=0
90
+ scope_intent=""
91
+ scope_acceptance=""
92
+ scope_files=""
93
+ scope_path="$root/.scope.json"
94
+ if [ -f "$scope_path" ]; then
95
+ # Prefer jq; fall back to python3 (mirrors hook-common.sh degrade policy).
96
+ if have_jq; then
97
+ scope_intent="$(jq -r '.intent // empty' "$scope_path" 2>/dev/null)"
98
+ scope_acceptance="$(jq -r '.acceptance // empty' "$scope_path" 2>/dev/null)"
99
+ scope_files="$(jq -r '(.files // []) | join(", ")' "$scope_path" 2>/dev/null)"
100
+ scope_exists=1
101
+ elif have_py; then
102
+ read -r scope_intent scope_acceptance scope_files <<EOF
103
+ $(python3 -c '
104
+ import json, sys
105
+ try:
106
+ d = json.load(open(sys.argv[1]))
107
+ print(d.get("intent","") or "")
108
+ print(d.get("acceptance","") or "")
109
+ print(", ".join(d.get("files",[]) or []))
110
+ except Exception:
111
+ sys.exit(1)
112
+ ' "$scope_path" 2>/dev/null)
113
+ EOF
114
+ [ $? -eq 0 ] && scope_exists=1 || scope_exists=0
115
+ fi
116
+ fi
117
+
118
+ # --- compose the anchor message ---------------------------------------------
119
+ if [ "$has_query" = "1" ]; then
120
+ query_line="$current_query"
121
+ else
122
+ query_line="(current request unavailable - no transcript in this event)"
123
+ fi
124
+
125
+ if [ "$scope_exists" != "1" ]; then
126
+ msg="INTENT ANCHOR (pre-compile) - no .scope.json found in $root.
127
+
128
+ Current request:
129
+ $query_line
130
+
131
+ You have NOT compiled your Anchor Set. Before editing files, write .scope.json
132
+ in the repo root:
133
+ intent: one operational sentence (what is strictly necessary)
134
+ files: the exact files you will touch
135
+ acceptance: the one deterministic check that decides done
136
+
137
+ Compile it now, then proceed. The scope tracks the request - it is how you stay
138
+ on the rails when the conversation gets long."
139
+ elif [ "$prompt_changed" = "1" ]; then
140
+ msg="INTENT ANCHOR (pre-compile) - your request changed; .scope.json may be stale.
141
+
142
+ Current request:
143
+ $query_line
144
+
145
+ Your existing contract (.scope.json):
146
+ intent: $scope_intent
147
+ files: $scope_files
148
+ acceptance: $scope_acceptance
149
+
150
+ If the current request differs from the intent above, UPDATE .scope.json now
151
+ to match what was just asked. When the request moves, the scope moves with it -
152
+ do not edit against a contract written for a different request."
153
+ else
154
+ # Same prompt continuing (or query unavailable) -> re-inject the contract.
155
+ if [ "$has_query" = "1" ]; then
156
+ drift_note="Every edit this turn must advance intent and stay inside files. acceptance is the bar for done."
157
+ else
158
+ drift_note="(request unavailable to diff against - re-injecting the contract as-is.)"
159
+ fi
160
+ msg="INTENT ANCHOR (re-injected this turn from .scope.json) - your contract. Do not drift from it.
161
+
162
+ intent: $scope_intent
163
+ files: $scope_files
164
+ acceptance: $scope_acceptance
165
+
166
+ $drift_note If a constraint above conflicts with what you are about to do, stop
167
+ and reconcile - the contract outranks momentum."
168
+ fi
169
+
170
+ # --- stash to the feedback bus (drained by post-tool-use.sh) -----------------
171
+ pending="$pending_dir/feedback-$cid.txt"
172
+ mkdir -p "$pending_dir" 2>/dev/null
173
+ if [ -s "$pending" ]; then
174
+ printf '\n\n---\n\n%s' "$msg" >> "$pending" 2>/dev/null
175
+ else
176
+ printf '%s' "$msg" >> "$pending" 2>/dev/null
177
+ fi
178
+
179
+ # --- arm the latch; record the query hash for next-turn change detection -----
180
+ touch "$latch" 2>/dev/null
181
+ if [ -n "$current_hash" ]; then
182
+ printf '%s' "$current_hash" > "$hash_file" 2>/dev/null
183
+ fi
184
+
185
+ exit 0
@@ -45,11 +45,12 @@ pending_dir="$(hooks_pending_dir)"
45
45
  marker="$pending_dir/session-edits-$cid.txt"
46
46
  flag="$pending_dir/reviewed-$cid.flag"
47
47
  anchor_flag="$pending_dir/anchor-declared-$cid.flag"
48
+ intent_latch="$pending_dir/intent-injected-$cid.flag"
48
49
 
49
- # Unconditionally clear the pre-compile nudge's per-turn latch so it re-fires
50
- # on the first edit of the next subagent run. Clearing here (not only inside
51
- # the reviewed-flag block below) can never strand the nudge silenced.
52
- rm -f "$anchor_flag" 2>/dev/null
50
+ # Unconditionally clear the per-turn latches so the next subagent run re-fires.
51
+ # Clearing here (not only inside the reviewed-flag block below) can never strand
52
+ # them silenced. last-query-<cid>.hash is kept (cross-turn prompt-change detect).
53
+ rm -f "$anchor_flag" "$intent_latch" 2>/dev/null
53
54
 
54
55
  # One-shot brake: the previous subagentStop for this id emitted the review.
55
56
  if [ -f "$flag" ]; then
package/linux/hooks.json CHANGED
@@ -47,6 +47,11 @@
47
47
  }
48
48
  ],
49
49
  "postToolUse": [
50
+ {
51
+ "command": "bash ~/.agents/hooks/intent-anchor.sh",
52
+ "timeout": 5,
53
+ "_comment": "5s: THIN INTENT COMPILATION (anti Salience Dilution). Registered FIRST so it appends to the feedback bus before post-tool-use.sh drains it (same-tool delivery). On the FIRST tool boundary of each turn (per-turn latch intent-injected-<cid>.flag, cleared unconditionally at every stop), (1) re-injects the existing .scope.json (intent/files/acceptance) into additional_context so the contract is back in the model's attentional focus before edits pile up - UNCONDITIONAL, no transcript needed; (2) if the current <user_query> hash differs from last-query-<cid>.hash, demands the agent UPDATE .scope.json to match the new request (scope tracks the request). No .scope.json -> demand one be written. Needs transcript_path for change-detection; degrades to silent there but re-injection still runs. Advisory only; never blocks. Disable: HOOKS_ENFORCE=0 or INTENT_ANCHOR_ENFORCE=0."
54
+ },
50
55
  {
51
56
  "command": "bash ~/.agents/hooks/post-tool-use.sh",
52
57
  "timeout": 5,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cursordoctrine",
3
- "version": "0.4.2",
4
- "description": "Thin self-review hooks for Cursor — the model is the auditor. Pre-compile Anchor Set phase (proactive intent compilation, fires once per turn), intent-trace final review (Tier 0), unified 13-item anti-slop checklist, operational slop detection.",
3
+ "version": "0.4.3",
4
+ "description": "Thin self-review hooks for Cursor — the model is the auditor. Proactive intent compilation (pre-compile Anchor Set + per-turn .scope.json re-injection against Salience Dilution), intent-trace final review (Tier 0), unified 13-item anti-slop checklist, operational slop detection.",
5
5
  "bin": {
6
6
  "cursordoctrine": "bin/cli.mjs"
7
7
  },
@@ -40,7 +40,8 @@ $cid = Get-SafeConversationId $obj
40
40
  $pendingDir = Get-HooksPendingDir
41
41
  $marker = Join-Path $pendingDir "session-edits-$cid.txt"
42
42
  $flag = Join-Path $pendingDir "reviewed-$cid.flag"
43
- $anchorFlag = Join-Path $pendingDir "anchor-declared-$cid.flag"
43
+ $anchorFlag = Join-Path $pendingDir "anchor-declared-$cid.flag"
44
+ $intentLatch = Join-Path $pendingDir "intent-injected-$cid.flag"
44
45
 
45
46
  # Sweep state from sessions that died before their stop hook ran. Cheap (one
46
47
  # directory listing on an event that fires once per agent loop).
@@ -48,14 +49,16 @@ Get-ChildItem $pendingDir -File -ErrorAction SilentlyContinue |
48
49
  Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-7) } |
49
50
  Remove-Item -Force -ErrorAction SilentlyContinue
50
51
 
51
- # Unconditionally clear the pre-compile nudge's per-turn latch. Every stop is a
52
- # turn boundary; clearing here (not only inside the reviewed-flag block below)
53
- # guarantees the nudge re-fires on the first edit of the NEXT turn and can never
54
- # get stranded silenced. The original design cleared anchor-declared-<cid>.flag
55
- # only on the SECOND stop (the reviewed-flag path), so any conversation that
56
- # never cleanly hit that boundary left the nudge permanently off - the agent
57
- # then stopped being reminded to write .scope.json.
58
- Remove-Item $anchorFlag -Force -ErrorAction SilentlyContinue
52
+ # Unconditionally clear the per-turn latches so the next turn re-fires. Every
53
+ # stop is a turn boundary; clearing here (not only inside the reviewed-flag
54
+ # block below) guarantees these re-fire on the first edit/tool of the NEXT
55
+ # turn and can never get stranded silenced mid-session:
56
+ # - anchor-declared-<cid>.flag (anchor-set-nudge, first-edit reminder)
57
+ # - intent-injected-<cid>.flag (intent-anchor, first-tool re-injection)
58
+ # last-query-<cid>.hash is NOT cleared here - it must persist turn-to-turn so
59
+ # intent-anchor can detect prompt changes; the 7-day sweep above reaps it when
60
+ # a conversation truly dies.
61
+ Remove-Item $anchorFlag, $intentLatch -Force -ErrorAction SilentlyContinue
59
62
 
60
63
  # One-shot brake: the previous stop for this conversation emitted the review.
61
64
  # Clear the flag (and whatever the review pass itself edited) and end the loop.
@@ -0,0 +1,167 @@
1
+ # intent-anchor.ps1 - postToolUse "thin intent compilation" anchor (Cursor).
2
+ #
3
+ # Counteracts Salience Dilution: the failure mode where the agent's original
4
+ # intent erodes as the conversation fills with code, logs and errors, until the
5
+ # token of the original request is a rounding error against the recent history
6
+ # and the agent drifts ("forgets" symmetry, colors, the .scope.json it wrote at
7
+ # prompt 1). Two jobs, both on the FIRST tool boundary of each turn (per-turn
8
+ # latch intent-injected-<cid>.flag, armed here, cleared at every stop):
9
+ #
10
+ # 1. RE-INJECT .scope.json (the core anti-dilution move): read the contract
11
+ # (intent + files + acceptance) and stash it in the feedback bus so
12
+ # post-tool-use.ps1 delivers it as additional_context at the next tool
13
+ # boundary. This puts the contract back in the model's attentional focus
14
+ # at the START of each turn's work, before edits pile up and dilute the
15
+ # original intent. Works UNCONDITIONALLY - no transcript needed.
16
+ #
17
+ # 2. RE-COMPILE ON PROMPT CHANGE: hash the current <user_query> (via
18
+ # Get-LastUserQuery, which reads the transcript) and compare to
19
+ # last-query-<cid>.hash. If they differ, demand the agent UPDATE
20
+ # .scope.json to match the new request. If no .scope.json exists, demand
21
+ # one be written. The scope tracks the request - when the request moves,
22
+ # the scope moves with it. This part needs transcript_path in the payload;
23
+ # if it is absent the hook degrades to silent on change-detection but the
24
+ # re-injection above still runs.
25
+ #
26
+ # Why postToolUse, not afterFileEdit: afterFileEdit only fires AFTER an edit
27
+ # exists, and Cursor has no preToolUse for file edits. postToolUse fires after
28
+ # EVERY tool (Read/Glob/Bash/Write/...), so its first fire of a turn is the
29
+ # earliest moment the agent has begun working - typically right after the first
30
+ # Read/Glob, before any edit. Best available injection point for "before files".
31
+ #
32
+ # Once per turn: latch armed on first fire, cleared UNCONDITIONALLY at every
33
+ # stop (final-review.ps1). Cannot strand silenced mid-session (that was the
34
+ # 0.4.0 bug). Registered first in the postToolUse array so it appends to the
35
+ # feedback bus before post-tool-use.ps1 drains it (same-tool delivery; if an
36
+ # updated install orders it after, delivery slips one tool - still correct).
37
+ #
38
+ # Advisory only: never blocks, never reads the diff, ALWAYS exits 0. Appends to
39
+ # the shared feedback-<cid>.txt bus. Disable: HOOKS_ENFORCE=0 or INTENT_ANCHOR_ENFORCE=0.
40
+
41
+ $ErrorActionPreference = 'SilentlyContinue'
42
+ . "$PSScriptRoot\hook-common.ps1"
43
+
44
+ if ($env:HOOKS_ENFORCE -eq '0' -or $env:INTENT_ANCHOR_ENFORCE -eq '0') { exit 0 }
45
+
46
+ $obj = Read-HookStdinJson
47
+ if (-not $obj) { exit 0 }
48
+
49
+ $cid = Get-SafeConversationId $obj
50
+ $pendingDir = Get-HooksPendingDir
51
+ $latch = Join-Path $pendingDir "intent-injected-$cid.flag"
52
+ $hashFile = Join-Path $pendingDir "last-query-$cid.hash"
53
+
54
+ # Already injected this turn -> quiet. Latch cleared at every stop.
55
+ if (Test-Path $latch) { exit 0 }
56
+
57
+ # --- current request (best-effort; absent in sandboxed runs) -----------------
58
+ $currentQuery = Get-LastUserQuery $obj
59
+ $hasQuery = -not [string]::IsNullOrWhiteSpace($currentQuery)
60
+
61
+ $currentHash = ''
62
+ $promptChanged = $false
63
+ if ($hasQuery) {
64
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($currentQuery)
65
+ $hasher = [System.Security.Cryptography.SHA256]::Create()
66
+ $currentHash = -join ($hasher.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') })
67
+ $prevHash = ''
68
+ if (Test-Path $hashFile) { $prevHash = (Get-Content $hashFile -Raw -ErrorAction SilentlyContinue).Trim() }
69
+ $promptChanged = ($currentHash -ne $prevHash)
70
+ }
71
+
72
+ # --- repo root (same resolution as scope-gate-audit.ps1) ---------------------
73
+ $root = ''
74
+ $cands = @()
75
+ if ($obj.PSObject.Properties['cwd'] -and $obj.cwd) { $cands += [string]$obj.cwd }
76
+ if ($obj.PSObject.Properties['workspace_roots']) { foreach ($w in $obj.workspace_roots) { $cands += [string]$w } }
77
+ foreach ($c in $cands) { $f = ConvertTo-FwdPath $c; if ($f -and (Test-Path -LiteralPath $f)) { $root = $f.TrimEnd('/'); break } }
78
+ if (-not $root) { $root = (& { if ($env:CURSOR_PROJECT_DIR) { $env:CURSOR_PROJECT_DIR } else { $HOME } }).Replace('\', '/').TrimEnd('/') }
79
+
80
+ # --- read the existing contract (if any) -------------------------------------
81
+ $scopeExists = $false
82
+ $scopeIntent = ''
83
+ $scopeAcceptance = ''
84
+ $scopeFiles = ''
85
+ $scopePath = Join-Path $root '.scope.json'
86
+ if (Test-Path -LiteralPath $scopePath) {
87
+ try {
88
+ $sj = Get-Content -LiteralPath $scopePath -Raw | ConvertFrom-Json
89
+ if ($sj.intent) { $scopeIntent = [string]$sj.intent }
90
+ if ($sj.acceptance) { $scopeAcceptance = [string]$sj.acceptance }
91
+ if ($sj.files) { $scopeFiles = ($sj.files -join ', ') }
92
+ $scopeExists = $true
93
+ } catch { $scopeExists = $false } # malformed JSON -> treat as missing
94
+ }
95
+
96
+ # --- compose the anchor message ---------------------------------------------
97
+ # Re-injection (req 2) is unconditional whenever a contract exists.
98
+ # Recompile-demand (req 1) fires when there is no contract, or the prompt moved.
99
+ $queryLine = if ($hasQuery) { $currentQuery } else { '(current request unavailable - no transcript in this event)' }
100
+
101
+ if (-not $scopeExists) {
102
+ $msg = @"
103
+ INTENT ANCHOR (pre-compile) - no .scope.json found in $root.
104
+
105
+ Current request:
106
+ $queryLine
107
+
108
+ You have NOT compiled your Anchor Set. Before editing files, write .scope.json
109
+ in the repo root:
110
+ intent: one operational sentence (what is strictly necessary)
111
+ files: the exact files you will touch
112
+ acceptance: the one deterministic check that decides done
113
+
114
+ Compile it now, then proceed. The scope tracks the request - it is how you stay
115
+ on the rails when the conversation gets long.
116
+ "@
117
+ } elseif ($promptChanged) {
118
+ $msg = @"
119
+ INTENT ANCHOR (pre-compile) - your request changed; .scope.json may be stale.
120
+
121
+ Current request:
122
+ $queryLine
123
+
124
+ Your existing contract (.scope.json):
125
+ intent: $scopeIntent
126
+ files: $scopeFiles
127
+ acceptance: $scopeAcceptance
128
+
129
+ If the current request differs from the intent above, UPDATE .scope.json now
130
+ to match what was just asked. When the request moves, the scope moves with it -
131
+ do not edit against a contract written for a different request.
132
+ "@
133
+ } else {
134
+ # Same prompt continuing (or query unavailable) -> re-inject the contract.
135
+ $driftNote = if ($hasQuery) {
136
+ "Every edit this turn must advance intent and stay inside files. acceptance is the bar for done."
137
+ } else {
138
+ "(request unavailable to diff against - re-injecting the contract as-is.)"
139
+ }
140
+ $msg = @"
141
+ INTENT ANCHOR (re-injected this turn from .scope.json) - your contract. Do not drift from it.
142
+
143
+ intent: $scopeIntent
144
+ files: $scopeFiles
145
+ acceptance: $scopeAcceptance
146
+
147
+ $driftNote If a constraint above conflicts with what you are about to do, stop
148
+ and reconcile - the contract outranks momentum.
149
+ "@
150
+ }
151
+
152
+ # --- stash to the feedback bus (drained by post-tool-use.ps1) ----------------
153
+ $pending = Join-Path $pendingDir "feedback-$cid.txt"
154
+ try {
155
+ New-Item -ItemType Directory -Path $pendingDir -Force | Out-Null
156
+ $prefix = ''
157
+ if ((Test-Path $pending) -and ((Get-Item $pending).Length -gt 0)) { $prefix = "`n`n---`n`n" }
158
+ Add-Content -Path $pending -Value ($prefix + $msg) -NoNewline
159
+ } catch { }
160
+
161
+ # --- arm the latch; record the query hash for next-turn change detection -----
162
+ New-Item -ItemType File -Path $latch -Force -ErrorAction SilentlyContinue | Out-Null
163
+ if ($currentHash) {
164
+ try { Set-Content -Path $hashFile -Value $currentHash -NoNewline -ErrorAction SilentlyContinue } catch { }
165
+ }
166
+
167
+ exit 0
@@ -43,12 +43,13 @@ $cid = Get-SafeConversationId $obj
43
43
  $pendingDir = Get-HooksPendingDir
44
44
  $marker = Join-Path $pendingDir "session-edits-$cid.txt"
45
45
  $flag = Join-Path $pendingDir "reviewed-$cid.flag"
46
- $anchorFlag = Join-Path $pendingDir "anchor-declared-$cid.flag"
46
+ $anchorFlag = Join-Path $pendingDir "anchor-declared-$cid.flag"
47
+ $intentLatch = Join-Path $pendingDir "intent-injected-$cid.flag"
47
48
 
48
- # Unconditionally clear the pre-compile nudge's per-turn latch so it re-fires
49
- # on the first edit of the next subagent run. Clearing here (not only inside
50
- # the reviewed-flag block below) can never strand the nudge silenced.
51
- Remove-Item $anchorFlag -Force -ErrorAction SilentlyContinue
49
+ # Unconditionally clear the per-turn latches so the next subagent run re-fires.
50
+ # Clearing here (not only inside the reviewed-flag block below) can never strand
51
+ # them silenced. last-query-<cid>.hash is kept (cross-turn prompt-change detect).
52
+ Remove-Item $anchorFlag, $intentLatch -Force -ErrorAction SilentlyContinue
52
53
 
53
54
  # One-shot brake: the previous subagentStop for this id emitted the review.
54
55
  if (Test-Path $flag) {
@@ -47,6 +47,11 @@
47
47
  }
48
48
  ],
49
49
  "postToolUse": [
50
+ {
51
+ "command": "pwsh.exe -NoProfile -File ~/.agents/hooks/intent-anchor.ps1",
52
+ "timeout": 5,
53
+ "_comment": "5s: THIN INTENT COMPILATION (anti Salience Dilution). Registered FIRST so it appends to the feedback bus before post-tool-use.ps1 drains it (same-tool delivery). On the FIRST tool boundary of each turn (per-turn latch intent-injected-<cid>.flag, cleared unconditionally at every stop), (1) re-injects the existing .scope.json (intent/files/acceptance) into additional_context so the contract is back in the model's attentional focus before edits pile up - UNCONDITIONAL, no transcript needed; (2) if the current <user_query> hash differs from last-query-<cid>.hash, demands the agent UPDATE .scope.json to match the new request (scope tracks the request). No .scope.json -> demand one be written. Needs transcript_path for change-detection; degrades to silent there but re-injection still runs. Advisory only; never blocks. Disable: HOOKS_ENFORCE=0 or INTENT_ANCHOR_ENFORCE=0."
54
+ },
50
55
  {
51
56
  "command": "pwsh.exe -NoProfile -File ~/.agents/hooks/post-tool-use.ps1",
52
57
  "timeout": 5,