cursordoctrine 0.4.4 → 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
@@ -98,11 +98,12 @@ The Anchor Set is skipped for trivial one-liners (typo, literal) — the `declar
98
98
 
99
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
100
 
101
- `intent-anchor` (`postToolUse`, registered first so it runs before `post-tool-use` drains the bus) does three things on the **first tool boundary of every turn** (per-turn latch, cleared unconditionally at each stop):
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. **Materialize the contract (0.4.4+).** If `.scope.json` is missing or invalid and the current `<user_query>` is available, the hook **writes a scaffold to disk** `intent` from your prompt, `files`/`acceptance` as obvious `<TODO: …>` placeholders. Contract creation is no longer probabilistic.
104
- 2. **Re-inject the contract.** Reads `.scope.json` and stashes `intent` + `files` + `acceptance` into the feedback bus, which `post-tool-use` delivers as `additional_context`.
105
- 3. **Re-compile on prompt change.** Hashes the current `<user_query>` and compares to the previous turn's hash. If the request moved and a non-scaffold contract already exists, it demands the agent **update** `.scope.json`.
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.
106
107
 
107
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.
108
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';
@@ -320,65 +321,84 @@ function verify() {
320
321
  return true;
321
322
  });
322
323
 
323
- check('intent-anchor scaffolds .scope.json and re-injects every turn', () => {
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).
324
330
  const drainedOf = (cidv) => runHook(hook('post-tool-use'), { conversation_id: cidv });
325
331
  const anchorCid = 'npxv4';
326
- const scopePath = join(HOME, '.scope.json');
332
+ const repoDir = HOME; // verify() runs under a pinned HOME; treat it as the repo
333
+ const scopePath = join(repoDir, '.scope.json');
327
334
  const transcriptPath = join(HOME, '.cursor', '.hooks-pending', 'verify-transcript-npxv4.jsonl');
328
- const testQuery = 'fix grid symmetry and color tokens';
335
+ const q1 = 'fix grid symmetry and color tokens';
336
+ const q2 = 'now add dark mode support';
329
337
 
330
338
  const cleanup = () => {
331
339
  try { rmSync(scopePath, { force: true }); } catch {}
332
340
  try { rmSync(transcriptPath, { force: true }); } catch {}
333
341
  };
334
342
  cleanup();
335
-
336
- // Fake transcript so Get-LastUserQuery / scaffold can read the request.
337
- const transcriptLine = JSON.stringify({
338
- role: 'user',
339
- message: { content: `<user_query>${testQuery}</user_query>` },
340
- });
341
- writeFileSync(transcriptPath, transcriptLine + '\n', 'utf8');
342
-
343
- const anchorPayload = { conversation_id: anchorCid, cwd: HOME, transcript_path: transcriptPath };
344
-
345
- // --- Case A: no .scope.json -> hook writes scaffold on disk ----------
346
- runHook(hook('intent-anchor'), anchorPayload);
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 });
347
351
  let d = drainedOf(anchorCid);
348
352
  if (!existsSync(scopePath)) {
349
- cleanup(); return { ok: false, detail: '.scope.json was not written to disk' };
353
+ cleanup(); return { ok: false, detail: 'scaffold was NOT written on first prompt' };
350
354
  }
351
355
  let scope;
352
- try { scope = JSON.parse(readFileSync(scopePath, 'utf8')); } catch {
353
- cleanup(); return { ok: false, detail: '.scope.json scaffold is not valid JSON' };
354
- }
355
- if (scope.intent !== testQuery) {
356
- cleanup(); return { ok: false, detail: `scaffold intent mismatch: ${scope.intent}` };
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}` };
357
360
  }
358
- if (!d.includes('scaffold written') || !d.includes(testQuery)) {
359
- cleanup(); return { ok: false, detail: 'scaffold branch did not inject written contract' };
361
+ if (!d.includes('scope regenerated')) {
362
+ cleanup(); return { ok: false, detail: 'regenerated branch did not fire' };
360
363
  }
361
364
 
362
- // --- Stop clears the latch -------------------------------------------
365
+ // --- Stop clears the latch -> next turn can act again --------------------
363
366
  runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
364
367
 
365
- // --- Case B: scope exists -> re-inject contract every turn -----------
366
- writeFileSync(scopePath, JSON.stringify({
367
- intent: 'fix grid symmetry and color tokens',
368
- files: ['src/grid.tsx'],
369
- acceptance: 'grid renders symmetric; tokens match palette',
370
- }));
371
- runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: HOME, transcript_path: transcriptPath });
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 });
372
371
  d = drainedOf(anchorCid);
373
- if (!d.includes('fix grid symmetry and color tokens') || !d.includes('INTENT ANCHOR')) {
374
- 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}` };
375
380
  }
376
381
 
382
+ // --- Stop; new prompt q2 -> REGENERATE with q2 as intent ----------------
377
383
  runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
378
- runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: HOME, transcript_path: transcriptPath });
384
+ writeTranscript(q2);
385
+ runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: repoDir, transcript_path: transcriptPath });
379
386
  d = drainedOf(anchorCid);
380
- if (!d.includes('fix grid symmetry and color tokens')) {
381
- 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' };
382
402
  }
383
403
  cleanup();
384
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 and a valid .scope.json exists,
21
- # demand the agent UPDATE it. If no valid .scope.json exists and the query
22
- # is available, WRITE a deterministic scaffold to disk (intent = query,
23
- # files/acceptance = TODO placeholders) so re-injection always has real
24
- # content from the first tool boundary contract creation is not left to
25
- # the LLM alone.
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,91 +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
116
132
  fi
117
133
 
118
- # --- deterministic scaffold (0.4.4) -------------------------------------------
119
- # When the query is available and there is no valid contract, write .scope.json
120
- # on disk intent from <user_query>, TODO placeholders for files/acceptance.
121
- scaffold_written=0
122
- should_scaffold=0
123
- [ "$has_query" = "1" ] && [ "$scope_exists" != "1" ] && should_scaffold=1
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
124
147
 
125
- if [ "$should_scaffold" = "1" ]; then
126
- if have_py; then
127
- if python3 -c '
128
- import json, sys
129
- path, intent = sys.argv[1], sys.argv[2]
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
130
159
  obj = {
131
- "intent": intent,
132
- "files": ["<TODO: list files>"],
133
- "acceptance": "<TODO: deterministic success check>",
160
+ "intent": os.environ["I_INTENT"],
161
+ "files": ["<TODO: list files you will touch>"],
162
+ "acceptance": "<TODO: the one deterministic check that decides done>",
134
163
  "allow_growth": False,
164
+ "_intent_hash": os.environ["I_HASH"],
165
+ "_generated_by": "intent-anchor hook",
135
166
  }
136
- with open(path, "w", encoding="utf-8") as f:
137
- json.dump(obj, f, ensure_ascii=False)
138
- ' "$scope_path" "$current_query" 2>/dev/null; then
139
- scaffold_written=1
140
- scope_exists=1
141
- scope_intent="$current_query"
142
- scope_acceptance="<TODO: deterministic success check>"
143
- scope_files="<TODO: list files>"
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
144
171
  fi
145
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
146
180
  fi
147
181
 
148
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.
149
185
  if [ "$has_query" = "1" ]; then
150
186
  query_line="$current_query"
151
187
  else
152
188
  query_line="(current request unavailable - no transcript in this event)"
153
189
  fi
154
190
 
155
- if [ "$scaffold_written" = "1" ]; then
156
- msg="INTENT ANCHOR (scaffold written to .scope.json) - contract materialized from your request.
191
+ if [ "$regenerated" = "1" ]; then
192
+ msg="INTENT ANCHOR (scope regenerated) - .scope.json written for this prompt.
157
193
 
158
194
  intent: $scope_intent
159
195
  files: $scope_files
160
196
  acceptance: $scope_acceptance
161
197
 
162
- The hook wrote this scaffold to $scope_path — intent is locked from your current
163
- request. Replace the TODO placeholders with real files[] and acceptance before
164
- editing source. The contract is on disk and will be re-injected every turn."
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."
165
202
  elif [ "$scope_exists" != "1" ]; then
166
- msg="INTENT ANCHOR (pre-compile) - no .scope.json found in $root.
203
+ msg="INTENT ANCHOR (pre-compile) - no .scope.json found in $root, and the current
204
+ request was unavailable to scaffold from.
167
205
 
168
206
  Current request:
169
207
  $query_line
170
208
 
171
- You have NOT compiled your Anchor Set. Before editing files, write .scope.json
172
- in the repo root:
209
+ Write .scope.json in the repo root yourself:
173
210
  intent: one operational sentence (what is strictly necessary)
174
211
  files: the exact files you will touch
175
- acceptance: the one deterministic check that decides done
176
-
177
- Compile it now, then proceed. The scope tracks the request - it is how you stay
178
- on the rails when the conversation gets long."
179
- elif [ "$prompt_changed" = "1" ]; then
180
- msg="INTENT ANCHOR (pre-compile) - your request changed; .scope.json may be stale.
181
-
182
- Current request:
183
- $query_line
184
-
185
- Your existing contract (.scope.json):
186
- intent: $scope_intent
187
- files: $scope_files
188
- acceptance: $scope_acceptance
189
-
190
- If the current request differs from the intent above, UPDATE .scope.json now
191
- to match what was just asked. When the request moves, the scope moves with it -
192
- do not edit against a contract written for a different request."
212
+ acceptance: the one deterministic check that decides done"
193
213
  else
194
- # Same prompt continuing (or query unavailable) -> re-inject the contract.
214
+ # Contract exists and matches the current prompt -> re-inject it.
195
215
  if [ "$has_query" = "1" ]; then
196
216
  drift_note="Every edit this turn must advance intent and stay inside files. acceptance is the bar for done."
197
217
  else
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cursordoctrine",
3
- "version": "0.4.4",
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 and a valid .scope.json exists,
20
- # demand the agent UPDATE it. If no valid .scope.json exists and the query
21
- # is available, WRITE a deterministic scaffold to disk (intent = query,
22
- # files/acceptance = TODO placeholders) so re-injection always has real
23
- # content from the first tool boundary contract creation is not left to
24
- # the LLM alone.
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,89 +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
 
96
- # --- deterministic scaffold (0.4.4) -----------------------------------------
97
- # When the query is available and there is no valid contract, the hook writes
98
- # .scope.json itself intent from <user_query>, obvious TODO placeholders
99
- # for files/acceptance. Fires on prompt change (incl. first turn: empty prev
100
- # hash) or whenever the contract is still missing on a turn boundary.
101
- $scaffoldWritten = $false
102
- $shouldScaffold = $hasQuery -and (-not $scopeExists)
103
- if ($shouldScaffold) {
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) {
104
127
  try {
105
128
  $scaffold = [ordered]@{
106
- intent = $currentQuery
107
- files = @('<TODO: list files>')
108
- acceptance = '<TODO: deterministic success check>'
109
- allow_growth = $false
110
- }
111
- $json = $scaffold | ConvertTo-Json -Depth 4 -Compress
112
- $dir = Split-Path -Parent $scopePath
113
- if ($dir -and -not (Test-Path -LiteralPath $dir)) {
114
- New-Item -ItemType Directory -Path $dir -Force | Out-Null
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'
115
135
  }
136
+ $json = $scaffold | ConvertTo-Json -Depth 5
116
137
  [System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
117
- $scopeIntent = $currentQuery
118
- $scopeAcceptance = '<TODO: deterministic success check>'
119
- $scopeFiles = '<TODO: list files>'
120
- $scopeExists = $true
121
- $scaffoldWritten = $true
122
- } catch { }
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
123
145
  }
124
146
 
125
147
  # --- compose the anchor message ---------------------------------------------
126
- # Re-injection (req 2) is unconditional whenever a contract exists.
127
- # Recompile-demand (req 1) fires when the prompt moved but a real contract exists.
148
+ # Three states: regenerated this turn (new prompt), no contract (and no query
149
+ # to scaffold from), or re-injecting an existing current contract.
128
150
  $queryLine = if ($hasQuery) { $currentQuery } else { '(current request unavailable - no transcript in this event)' }
129
151
 
130
- if ($scaffoldWritten) {
152
+ if ($regenerated) {
131
153
  $msg = @"
132
- INTENT ANCHOR (scaffold written to .scope.json) - contract materialized from your request.
154
+ INTENT ANCHOR (scope regenerated) - .scope.json written for this prompt.
133
155
 
134
156
  intent: $scopeIntent
135
157
  files: $scopeFiles
136
158
  acceptance: $scopeAcceptance
137
159
 
138
- The hook wrote this scaffold to $scopePath — intent is locked from your current
139
- request. Replace the TODO placeholders with real files[] and acceptance before
140
- editing source. The contract is on disk and will be re-injected every turn.
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.
141
164
  "@
142
165
  } elseif (-not $scopeExists) {
143
166
  $msg = @"
144
- INTENT ANCHOR (pre-compile) - no .scope.json found in $root.
167
+ INTENT ANCHOR (pre-compile) - no .scope.json found in $root, and the current
168
+ request was unavailable to scaffold from.
145
169
 
146
170
  Current request:
147
171
  $queryLine
148
172
 
149
- You have NOT compiled your Anchor Set. Before editing files, write .scope.json
150
- in the repo root:
173
+ Write .scope.json in the repo root yourself:
151
174
  intent: one operational sentence (what is strictly necessary)
152
175
  files: the exact files you will touch
153
176
  acceptance: the one deterministic check that decides done
154
-
155
- Compile it now, then proceed. The scope tracks the request - it is how you stay
156
- on the rails when the conversation gets long.
157
- "@
158
- } elseif ($promptChanged) {
159
- $msg = @"
160
- INTENT ANCHOR (pre-compile) - your request changed; .scope.json may be stale.
161
-
162
- Current request:
163
- $queryLine
164
-
165
- Your existing contract (.scope.json):
166
- intent: $scopeIntent
167
- files: $scopeFiles
168
- acceptance: $scopeAcceptance
169
-
170
- If the current request differs from the intent above, UPDATE .scope.json now
171
- to match what was just asked. When the request moves, the scope moves with it -
172
- do not edit against a contract written for a different request.
173
177
  "@
174
178
  } else {
175
- # Same prompt continuing (or query unavailable) -> re-inject the contract.
179
+ # Contract exists and matches the current prompt -> re-inject it.
176
180
  $driftNote = if ($hasQuery) {
177
181
  "Every edit this turn must advance intent and stay inside files. acceptance is the bar for done."
178
182
  } else {