cursordoctrine 0.4.3 → 0.4.6

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/README.md CHANGED
@@ -100,8 +100,10 @@ Writing `.scope.json` once is not enough. As a conversation fills with code, log
100
100
 
101
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
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.)
103
+ 1. **Materialize the contract per prompt.** When the current `<user_query>` differs from the contract on disk (no contract yet, or its recorded `_intent_hash` doesn't match), the hook **writes a fresh `.scope.json` to the repo root**: `intent` locked from the prompt, `files`/`acceptance` as `<TODO>` placeholders the agent fills in. Every new prompt a fresh contract the agent works from. Same prompt no rewrite.
104
+ 2. **Re-inject the contract every turn.** 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, before edits pile up and dilute it.
105
+
106
+ > **The hook writes `.scope.json` deliberately.** This is the intended behavior: the contract must exist *before* the agent edits, and it must track the request. The agent fills the `<TODO>` placeholders (`files`, `acceptance`) on the first turn; the hook regenerates the scaffold when the prompt changes. Two real bugs in the earlier 0.4.4 build are fixed here: (a) **never writes to `$HOME`** — if the repo `cwd` can't be resolved the hook stays silent rather than drop a ghost file (bail instead of fallback); (b) **regenerates on prompt CHANGE, not on every turn** — staleness is tracked via `_intent_hash` stored in the file itself, so a same-prompt turn re-injects without rewriting.
105
107
 
106
108
  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
109
 
package/bin/cli.mjs CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  readFileSync,
18
18
  readdirSync,
19
19
  rmSync,
20
+ statSync,
20
21
  writeFileSync,
21
22
  } from 'node:fs';
22
23
  import { join, resolve, dirname } from 'node:path';
@@ -74,6 +75,26 @@ function mergeHooks(existing, incoming, keys) {
74
75
  if (i >= 0) cur[i] = entry;
75
76
  else cur.push(entry);
76
77
  }
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));
82
+ const reordered = [];
83
+ const used = new Set();
84
+ for (const entry of entries) {
85
+ const k = keyOf(entry.command, keys);
86
+ if (!k || !isOurs(entry.command, keys)) continue;
87
+ const found = cur.find((x) => x && keyOf(x.command, keys) === k);
88
+ if (found) {
89
+ reordered.push(found);
90
+ used.add(k);
91
+ }
92
+ }
93
+ for (const x of cur) {
94
+ const k = keyOf(x?.command, keys);
95
+ if (isOurs(x?.command, keys) && k && !used.has(k)) reordered.push(x);
96
+ }
97
+ out.hooks[event] = [...reordered, ...foreign];
77
98
  }
78
99
  let preserved = 0;
79
100
  for (const entries of Object.values(out.hooks)) {
@@ -300,45 +321,84 @@ function verify() {
300
321
  return true;
301
322
  });
302
323
 
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.
324
+ check('intent-anchor scaffolds .scope.json per-prompt, never to $HOME', () => {
325
+ // The user-requested behavior: every NEW prompt -> a fresh .scope.json
326
+ // in the repo root, intent locked from the query. Same prompt -> re-inject
327
+ // without rewriting. Never writes to $HOME (the 0.4.4 ghost-file bug).
328
+ // We drive it with a synthetic transcript so Get-LastUserQuery can read
329
+ // the <user_query>; the hook resolves cwd -> HOME (the repo root here).
306
330
  const drainedOf = (cidv) => runHook(hook('post-tool-use'), { conversation_id: cidv });
307
331
  const anchorCid = 'npxv4';
308
- const scopePath = join(HOME, '.scope.json');
309
-
310
- const cleanup = () => { try { rmSync(scopePath, { force: true }); } catch {} };
332
+ const repoDir = HOME; // verify() runs under a pinned HOME; treat it as the repo
333
+ const scopePath = join(repoDir, '.scope.json');
334
+ const transcriptPath = join(HOME, '.cursor', '.hooks-pending', 'verify-transcript-npxv4.jsonl');
335
+ const q1 = 'fix grid symmetry and color tokens';
336
+ const q2 = 'now add dark mode support';
337
+
338
+ const cleanup = () => {
339
+ try { rmSync(scopePath, { force: true }); } catch {}
340
+ try { rmSync(transcriptPath, { force: true }); } catch {}
341
+ };
311
342
  cleanup();
312
-
313
- // --- Case A: no .scope.json -> demand one be written --------------
314
- runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: HOME });
343
+ const writeTranscript = (q) =>
344
+ writeFileSync(transcriptPath,
345
+ JSON.stringify({ role: 'user', message: { content: `<user_query>${q}</user_query>` } }) + '\n',
346
+ 'utf8');
347
+
348
+ // --- Case A: no .scope.json + prompt q1 -> WRITE scaffold with q1 as intent -
349
+ writeTranscript(q1);
350
+ runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: repoDir, transcript_path: transcriptPath });
315
351
  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' };
352
+ if (!existsSync(scopePath)) {
353
+ cleanup(); return { ok: false, detail: 'scaffold was NOT written on first prompt' };
354
+ }
355
+ let scope;
356
+ try { scope = JSON.parse(readFileSync(scopePath, 'utf8')); }
357
+ catch { cleanup(); return { ok: false, detail: '.scope.json is not valid JSON' }; }
358
+ if (scope.intent !== q1) {
359
+ cleanup(); return { ok: false, detail: `intent mismatch (want "${q1}"): ${scope.intent}` };
360
+ }
361
+ if (!d.includes('scope regenerated')) {
362
+ cleanup(); return { ok: false, detail: 'regenerated branch did not fire' };
318
363
  }
319
364
 
320
- // --- Stop clears the latch (the regression path from 0.4.1) -------
365
+ // --- Stop clears the latch -> next turn can act again --------------------
321
366
  runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
322
367
 
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 });
368
+ // --- Case B: same prompt q1 again -> re-INJECT, do NOT rewrite ----------
369
+ const sizeBefore = statSync(scopePath).size;
370
+ runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: repoDir, transcript_path: transcriptPath });
331
371
  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' };
372
+ if (!d.includes('re-injected this turn')) {
373
+ cleanup(); return { ok: false, detail: 'same-prompt turn did not re-inject' };
374
+ }
375
+ // _intent_hash matches -> file must not have been regenerated. Intent still q1.
376
+ try { scope = JSON.parse(readFileSync(scopePath, 'utf8')); }
377
+ catch { cleanup(); return { ok: false, detail: '.scope.json corrupted on same-prompt turn' }; }
378
+ if (scope.intent !== q1) {
379
+ cleanup(); return { ok: false, detail: `same-prompt turn rewrote intent (should stay "${q1}"): ${scope.intent}` };
334
380
  }
335
381
 
336
- // --- Stop clears the latch again; turn 3 must re-inject too -------
382
+ // --- Stop; new prompt q2 -> REGENERATE with q2 as intent ----------------
337
383
  runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
338
- runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: HOME });
384
+ writeTranscript(q2);
385
+ runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: repoDir, transcript_path: transcriptPath });
339
386
  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)' };
387
+ try { scope = JSON.parse(readFileSync(scopePath, 'utf8')); }
388
+ catch { cleanup(); return { ok: false, detail: '.scope.json corrupted on prompt-change turn' }; }
389
+ if (scope.intent !== q2) {
390
+ cleanup(); return { ok: false, detail: `prompt-change did not regenerate intent (want "${q2}"): ${scope.intent}` };
391
+ }
392
+ if (!d.includes('scope regenerated')) {
393
+ cleanup(); return { ok: false, detail: 'prompt-change turn did not report regeneration' };
394
+ }
395
+
396
+ // --- The anti-ghost-file guard: no .scope.json should ever exist at $HOME
397
+ // level OUTSIDE the resolved repo. Here repo == HOME so this is moot,
398
+ // but assert the file has the _generated_by marker (proof the hook wrote
399
+ // it, and that it carries _intent_hash for self-contained staleness).
400
+ if (!scope._generated_by || !scope._intent_hash) {
401
+ cleanup(); return { ok: false, detail: 'scaffold missing _generated_by / _intent_hash fields' };
342
402
  }
343
403
  cleanup();
344
404
  return true;
@@ -15,14 +15,17 @@
15
15
  # at the START of each turn's work, before edits pile up and dilute the
16
16
  # original intent. Works UNCONDITIONALLY - no transcript needed.
17
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.
18
+ # 2. AUTO-CREATE / REGENERATE .scope.json: when the current <user_query>
19
+ # differs from the contract on disk (no contract yet, OR _intent_hash
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.
26
+ # 3. RE-INJECT on same-prompt turns: when the query is unchanged (contract
27
+ # already current), the hook re-injects the existing contract into the
28
+ # feedback bus so it stays in the model's attentional focus each turn.
26
29
  #
27
30
  # Why postToolUse, not afterFileEdit: afterFileEdit only fires AFTER an edit
28
31
  # exists, and Cursor has no preToolUse for file edits. postToolUse fires after
@@ -74,7 +77,10 @@ if [ "$has_query" = "1" ]; then
74
77
  [ "$current_hash" != "$prev_hash" ] && prompt_changed=1
75
78
  fi
76
79
 
77
- # --- repo root (same resolution as scope-gate-audit.sh) ----------------------
80
+ # --- repo root (same resolution as scope-gate-audit.sh, but NO $HOME fallback) -
81
+ # We do NOT fall back to $HOME: writing .scope.json into $HOME was the 0.4.4
82
+ # "ghost file" bug. If we cannot resolve a real project root, the hook stays
83
+ # silent (no scaffold, no demand) rather than litter the user's home dir.
78
84
  root=""
79
85
  while IFS= read -r cand; do
80
86
  [ -n "$cand" ] && [ -d "$cand" ] && { root="${cand%/}"; break; }
@@ -82,14 +88,18 @@ done <<EOF
82
88
  $(json_get "$input" cwd)
83
89
  $(json_get_array "$input" workspace_roots)
84
90
  EOF
85
- [ -n "$root" ] || root="${CURSOR_PROJECT_DIR:-$HOME}"
86
- root="${root%/}"
91
+ if [ -z "$root" ] && [ -n "$CURSOR_PROJECT_DIR" ] && [ -d "$CURSOR_PROJECT_DIR" ]; then
92
+ root="${CURSOR_PROJECT_DIR%/}"
93
+ fi
94
+ # No $HOME fallback. If we still have no root, bail (cannot know where to write).
95
+ [ -n "$root" ] || exit 0
87
96
 
88
97
  # --- read the existing contract (if any) -------------------------------------
89
98
  scope_exists=0
90
99
  scope_intent=""
91
100
  scope_acceptance=""
92
101
  scope_files=""
102
+ scope_stale=0 # 1 if on-disk contract predates the current query
93
103
  scope_path="$root/.scope.json"
94
104
  if [ -f "$scope_path" ]; then
95
105
  # Prefer jq; fall back to python3 (mirrors hook-common.sh degrade policy).
@@ -97,9 +107,10 @@ if [ -f "$scope_path" ]; then
97
107
  scope_intent="$(jq -r '.intent // empty' "$scope_path" 2>/dev/null)"
98
108
  scope_acceptance="$(jq -r '.acceptance // empty' "$scope_path" 2>/dev/null)"
99
109
  scope_files="$(jq -r '(.files // []) | join(", ")' "$scope_path" 2>/dev/null)"
110
+ on_disk_hash="$(jq -r '._intent_hash // empty' "$scope_path" 2>/dev/null)"
100
111
  scope_exists=1
101
112
  elif have_py; then
102
- read -r scope_intent scope_acceptance scope_files <<EOF
113
+ read -r scope_intent scope_acceptance scope_files on_disk_hash <<EOF
103
114
  $(python3 -c '
104
115
  import json, sys
105
116
  try:
@@ -107,51 +118,100 @@ try:
107
118
  print(d.get("intent","") or "")
108
119
  print(d.get("acceptance","") or "")
109
120
  print(", ".join(d.get("files",[]) or []))
121
+ print(d.get("_intent_hash","") or "")
110
122
  except Exception:
111
123
  sys.exit(1)
112
124
  ' "$scope_path" 2>/dev/null)
113
125
  EOF
114
126
  [ $? -eq 0 ] && scope_exists=1 || scope_exists=0
115
127
  fi
128
+ # Stale if we have a query AND the on-disk _intent_hash differs from it.
129
+ if [ "$scope_exists" = "1" ] && [ "$has_query" = "1" ] && [ -n "$on_disk_hash" ]; then
130
+ [ "$on_disk_hash" != "$current_hash" ] && scope_stale=1
131
+ fi
132
+ fi
133
+
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".
140
+ 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
146
+ fi
147
+
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).
152
+ 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"}' \
155
+ > "$scope_path" 2>/dev/null && regenerated=1
156
+ elif have_py; then
157
+ if I_FILE="$scope_path" I_INTENT="$current_query" I_HASH="$current_hash" python3 -c '
158
+ import json, os
159
+ obj = {
160
+ "intent": os.environ["I_INTENT"],
161
+ "files": ["<TODO: list files you will touch>"],
162
+ "acceptance": "<TODO: the one deterministic check that decides done>",
163
+ "allow_growth": False,
164
+ "_intent_hash": os.environ["I_HASH"],
165
+ "_generated_by": "intent-anchor hook",
166
+ }
167
+ with open(os.environ["I_FILE"], "w", encoding="utf-8") as f:
168
+ json.dump(obj, f, ensure_ascii=False, indent=2)
169
+ ' 2>/dev/null; then
170
+ regenerated=1
171
+ fi
172
+ fi
173
+ if [ "$regenerated" = "1" ]; then
174
+ scope_intent="$current_query"
175
+ scope_acceptance="<TODO: the one deterministic check that decides done>"
176
+ scope_files="<TODO: list files you will touch>"
177
+ scope_exists=1
178
+ scope_stale=0
179
+ fi
116
180
  fi
117
181
 
118
182
  # --- compose the anchor message ---------------------------------------------
183
+ # Three states: regenerated this turn (new prompt), no contract (and no query
184
+ # to scaffold from), or re-injecting an existing current contract.
119
185
  if [ "$has_query" = "1" ]; then
120
186
  query_line="$current_query"
121
187
  else
122
188
  query_line="(current request unavailable - no transcript in this event)"
123
189
  fi
124
190
 
125
- if [ "$scope_exists" != "1" ]; then
126
- msg="INTENT ANCHOR (pre-compile) - no .scope.json found in $root.
191
+ if [ "$regenerated" = "1" ]; then
192
+ msg="INTENT ANCHOR (scope regenerated) - .scope.json written for this prompt.
127
193
 
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
194
+ intent: $scope_intent
195
+ files: $scope_files
196
+ acceptance: $scope_acceptance
136
197
 
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.
198
+ 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."
202
+ elif [ "$scope_exists" != "1" ]; then
203
+ msg="INTENT ANCHOR (pre-compile) - no .scope.json found in $root, and the current
204
+ request was unavailable to scaffold from.
141
205
 
142
206
  Current request:
143
207
  $query_line
144
208
 
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."
209
+ Write .scope.json in the repo root yourself:
210
+ intent: one operational sentence (what is strictly necessary)
211
+ files: the exact files you will touch
212
+ acceptance: the one deterministic check that decides done"
153
213
  else
154
- # Same prompt continuing (or query unavailable) -> re-inject the contract.
214
+ # Contract exists and matches the current prompt -> re-inject it.
155
215
  if [ "$has_query" = "1" ]; then
156
216
  drift_note="Every edit this turn must advance intent and stay inside files. acceptance is the bar for done."
157
217
  else
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cursordoctrine",
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.",
3
+ "version": "0.4.6",
4
+ "description": "Thin self-review hooks for Cursor — the model is the auditor. Proactive intent compilation (pre-compile Anchor Set + auto-scaffolded .scope.json that regenerates per prompt and re-injects every turn 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
  },
@@ -14,14 +14,17 @@
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. 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.
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
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.
25
+ # 3. RE-INJECT on same-prompt turns: when the query is unchanged (contract
26
+ # already current), the hook re-injects the existing contract into the
27
+ # feedback bus so it stays in the model's attentional focus each turn.
25
28
  #
26
29
  # Why postToolUse, not afterFileEdit: afterFileEdit only fires AFTER an edit
27
30
  # exists, and Cursor has no preToolUse for file edits. postToolUse fires after
@@ -70,18 +73,28 @@ if ($hasQuery) {
70
73
  }
71
74
 
72
75
  # --- repo root (same resolution as scope-gate-audit.ps1) ---------------------
76
+ # Resolve cwd -> workspace_roots -> CURSOR_PROJECT_DIR. We do NOT fall back to
77
+ # $HOME: writing .scope.json into $HOME was the 0.4.4 "ghost file" bug. If we
78
+ # cannot resolve a real project root, the hook stays silent (no scaffold, no
79
+ # demand) rather than litter the user's home directory.
73
80
  $root = ''
74
81
  $cands = @()
75
82
  if ($obj.PSObject.Properties['cwd'] -and $obj.cwd) { $cands += [string]$obj.cwd }
76
83
  if ($obj.PSObject.Properties['workspace_roots']) { foreach ($w in $obj.workspace_roots) { $cands += [string]$w } }
77
84
  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('/') }
85
+ if (-not $root -and $env:CURSOR_PROJECT_DIR) {
86
+ $cpd = $env:CURSOR_PROJECT_DIR.Replace('\', '/').TrimEnd('/')
87
+ if (Test-Path -LiteralPath $cpd) { $root = $cpd }
88
+ }
89
+ # No $HOME fallback. If we still have no root, bail (cannot know where to write).
90
+ if (-not $root) { exit 0 }
79
91
 
80
92
  # --- read the existing contract (if any) -------------------------------------
81
93
  $scopeExists = $false
82
94
  $scopeIntent = ''
83
95
  $scopeAcceptance = ''
84
96
  $scopeFiles = ''
97
+ $scopeStale = $false # true if the on-disk contract predates the current query
85
98
  $scopePath = Join-Path $root '.scope.json'
86
99
  if (Test-Path -LiteralPath $scopePath) {
87
100
  try {
@@ -90,48 +103,80 @@ if (Test-Path -LiteralPath $scopePath) {
90
103
  if ($sj.acceptance) { $scopeAcceptance = [string]$sj.acceptance }
91
104
  if ($sj.files) { $scopeFiles = ($sj.files -join ', ') }
92
105
  $scopeExists = $true
106
+ # The contract is "stale" if its recorded intent hash != current query
107
+ # hash. We persist the query hash inside .scope.json under _intent_hash
108
+ # so staleness survives even if last-query-<cid>.hash was swept.
109
+ if ($hasQuery -and $sj.PSObject.Properties['_intent_hash']) {
110
+ $scopeStale = ([string]$sj._intent_hash -ne $currentHash)
111
+ }
93
112
  } catch { $scopeExists = $false } # malformed JSON -> treat as missing
94
113
  }
95
114
 
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.
124
+ $regenerated = $false
125
+ $shouldWrite = $hasQuery -and (-not $scopeExists -or $scopeStale)
126
+ if ($shouldWrite) {
127
+ try {
128
+ $scaffold = [ordered]@{
129
+ intent = $currentQuery
130
+ files = @('<TODO: list files you will touch>')
131
+ acceptance = '<TODO: the one deterministic check that decides done>'
132
+ allow_growth = $false
133
+ _intent_hash = $currentHash
134
+ _generated_by = 'intent-anchor hook'
135
+ }
136
+ $json = $scaffold | ConvertTo-Json -Depth 5
137
+ [System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
138
+ $scopeIntent = $currentQuery
139
+ $scopeAcceptance = '<TODO: the one deterministic check that decides done>'
140
+ $scopeFiles = '<TODO: list files you will touch>'
141
+ $scopeExists = $true
142
+ $scopeStale = $false
143
+ $regenerated = $true
144
+ } catch { } # write failed (perms / locked) -> fall through to demand msg
145
+ }
146
+
96
147
  # --- 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.
148
+ # Three states: regenerated this turn (new prompt), no contract (and no query
149
+ # to scaffold from), or re-injecting an existing current contract.
99
150
  $queryLine = if ($hasQuery) { $currentQuery } else { '(current request unavailable - no transcript in this event)' }
100
151
 
101
- if (-not $scopeExists) {
152
+ if ($regenerated) {
102
153
  $msg = @"
103
- INTENT ANCHOR (pre-compile) - no .scope.json found in $root.
104
-
105
- Current request:
106
- $queryLine
154
+ INTENT ANCHOR (scope regenerated) - .scope.json written for this prompt.
107
155
 
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
156
+ intent: $scopeIntent
157
+ files: $scopeFiles
158
+ acceptance: $scopeAcceptance
113
159
 
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.
160
+ 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.
116
164
  "@
117
- } elseif ($promptChanged) {
165
+ } elseif (-not $scopeExists) {
118
166
  $msg = @"
119
- INTENT ANCHOR (pre-compile) - your request changed; .scope.json may be stale.
167
+ INTENT ANCHOR (pre-compile) - no .scope.json found in $root, and the current
168
+ request was unavailable to scaffold from.
120
169
 
121
170
  Current request:
122
171
  $queryLine
123
172
 
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.
173
+ Write .scope.json in the repo root yourself:
174
+ intent: one operational sentence (what is strictly necessary)
175
+ files: the exact files you will touch
176
+ acceptance: the one deterministic check that decides done
132
177
  "@
133
178
  } else {
134
- # Same prompt continuing (or query unavailable) -> re-inject the contract.
179
+ # Contract exists and matches the current prompt -> re-inject it.
135
180
  $driftNote = if ($hasQuery) {
136
181
  "Every edit this turn must advance intent and stay inside files. acceptance is the bar for done."
137
182
  } else {