cursordoctrine 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.mjs CHANGED
@@ -334,6 +334,25 @@ function verify() {
334
334
  JSON.stringify({ role: 'user', message: { content: `<user_query>${q}</user_query>` } }) + '\n',
335
335
  'utf8');
336
336
 
337
+ // --- Case 0: no .scope.json + NO transcript -> WRITE scaffold anyway -------
338
+ // This is the 0.5.3 regression: creation was gated on $hasQuery, so when
339
+ // Cursor didn't surface transcript_path in the first postToolUse fire, the
340
+ // scope never appeared. Now creation is unconditional on a real root.
341
+ runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: repoDir });
342
+ let d0 = drainedOf(anchorCid);
343
+ if (!existsSync(scopePath)) {
344
+ cleanup(); return { ok: false, detail: 'scaffold NOT created without transcript (0.5.3 regression)' };
345
+ }
346
+ let scope0;
347
+ try { scope0 = JSON.parse(readFileSync(scopePath, 'utf8')); }
348
+ catch { cleanup(); return { ok: false, detail: '.scope.json (no-transcript) is not valid JSON' }; }
349
+ if (!scope0.intent || !scope0.intent.includes('TODO')) {
350
+ cleanup(); return { ok: false, detail: `no-transcript scaffold should have intent <TODO>, got: ${scope0.intent}` };
351
+ }
352
+ // Clear the latch + scope so Case A starts fresh (with a real query this time).
353
+ runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
354
+ cleanup();
355
+
337
356
  // --- Case A: no .scope.json + prompt q1 -> WRITE scaffold with q1 as intent -
338
357
  writeTranscript(q1);
339
358
  runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: repoDir, transcript_path: transcriptPath });
@@ -389,6 +408,56 @@ function verify() {
389
408
  if (!scope._generated_by || !scope._intent_hash) {
390
409
  cleanup(); return { ok: false, detail: 'scaffold missing _generated_by / _intent_hash fields' };
391
410
  }
411
+
412
+ // --- Case C: MODEL-written scope (no _intent_hash) + a NEW prompt --------
413
+ // Reproduces the shipped bug (chiquipuesto/WAVE): the model wrote .scope.json
414
+ // per the legacy 4-field schema, so the hook could never detect staleness and
415
+ // scope-gate kept appending files across features. A new prompt (fresh cid =>
416
+ // promptChanged) must now REGENERATE and RESET files[].
417
+ const mkT = (p, q) => writeFileSync(p,
418
+ JSON.stringify({ role: 'user', message: { content: `<user_query>${q}</user_query>` } }) + '\n', 'utf8');
419
+ const cidC = 'npxv5';
420
+ const transcriptC = join(HOME, '.cursor', '.hooks-pending', 'verify-transcript-npxv5.jsonl');
421
+ const failC = (detail) => { try { rmSync(transcriptC, { force: true }); } catch {} cleanup(); return { ok: false, detail }; };
422
+ try { rmSync(scopePath, { force: true }); } catch {}
423
+ writeFileSync(scopePath, JSON.stringify({ intent: q1, files: ['a.ts', 'b.ts'], acceptance: 'keep', allow_growth: false }, null, 2), 'utf8');
424
+ mkT(transcriptC, q2);
425
+ runHook(hook('intent-anchor'), { conversation_id: cidC, cwd: repoDir, transcript_path: transcriptC });
426
+ drainedOf(cidC);
427
+ let sc;
428
+ try { sc = JSON.parse(readFileSync(scopePath, 'utf8')); } catch { return failC('model-written scope corrupted after new prompt'); }
429
+ if (sc.intent !== q2) return failC(`carryover not regenerated (want "${q2}"): ${sc.intent}`);
430
+ if (!Array.isArray(sc.files) || sc.files.length !== 0) return failC(`files[] not reset on regen: ${JSON.stringify(sc.files)}`);
431
+ if (!sc._intent_hash) return failC('regen did not install _intent_hash');
432
+ if (!sc.trace || sc.trace.query !== q2) return failC('regen did not record trace.query');
433
+ runHook(hook('final-review'), { conversation_id: cidC, status: 'completed' });
434
+ rmSync(transcriptC, { force: true });
435
+
436
+ // --- Case D: MODEL-written scope (no _intent_hash) + the SAME prompt ------
437
+ // The model wrote the contract for THIS request this session. The hook must
438
+ // HEAL in place (backfill _intent_hash + trace) WITHOUT resetting files[] or
439
+ // acceptance, so the NEXT prompt change is detectable by hash.
440
+ const cidD = 'npxv6';
441
+ const transcriptD = join(HOME, '.cursor', '.hooks-pending', 'verify-transcript-npxv6.jsonl');
442
+ const failD = (detail) => { try { rmSync(transcriptD, { force: true }); } catch {} cleanup(); return { ok: false, detail }; };
443
+ try { rmSync(scopePath, { force: true }); } catch {}
444
+ mkT(transcriptD, q1);
445
+ // prime last-query-<cidD>.hash with q1 (a hook-written scaffold), clear the
446
+ // latch, then overwrite with a model-style scope for the SAME prompt.
447
+ runHook(hook('intent-anchor'), { conversation_id: cidD, cwd: repoDir, transcript_path: transcriptD });
448
+ runHook(hook('final-review'), { conversation_id: cidD, status: 'completed' });
449
+ writeFileSync(scopePath, JSON.stringify({ intent: q1, files: ['a.ts', 'b.ts'], acceptance: 'keep me', allow_growth: false }, null, 2), 'utf8');
450
+ runHook(hook('intent-anchor'), { conversation_id: cidD, cwd: repoDir, transcript_path: transcriptD });
451
+ drainedOf(cidD);
452
+ let sd;
453
+ try { sd = JSON.parse(readFileSync(scopePath, 'utf8')); } catch { return failD('healed scope corrupted'); }
454
+ if (!sd._intent_hash) return failD('heal did not backfill _intent_hash');
455
+ if (!sd.trace) return failD('heal did not add trace');
456
+ if (!Array.isArray(sd.files) || sd.files.join(',') !== 'a.ts,b.ts') return failD(`heal reset files[] (should preserve): ${JSON.stringify(sd.files)}`);
457
+ if (sd.acceptance !== 'keep me') return failD(`heal clobbered acceptance: ${sd.acceptance}`);
458
+ runHook(hook('final-review'), { conversation_id: cidD, status: 'completed' });
459
+ rmSync(transcriptD, { force: true });
460
+
392
461
  cleanup();
393
462
  return true;
394
463
  });
@@ -122,5 +122,18 @@ Determinism / purity:
122
122
  - In-place mutation of shared state (arr.push, obj.prop =) when a caller holds
123
123
  a reference -> return new structures ([...arr, x], .map/.filter).
124
124
 
125
+ Logic & structure:
126
+ - Arrow code: >2 levels of nested if/for -> flatten with guard clauses
127
+ (early returns). Code reads top-to-bottom, no deep indent.
128
+ - Switch/if-else bloat: a switch or 5+ if/else branches -> Map/dispatch
129
+ (Record<State, fn>) or the Command pattern.
130
+ - Mixed abstraction (SLAP): a function mixing DB calls + string validation +
131
+ date formatting -> one level of abstraction per function; extract helpers.
132
+ - Primitive obsession: a primitive with business rules (email, userId, chainId)
133
+ passed as a bare string/number across functions -> a named type/value object.
134
+ - Imperative transforms: a `for` loop building an array when the language has
135
+ .map/.filter/.reduce -> use the declarative form; reserve `for` for cases
136
+ map/reduce cannot express.
137
+
125
138
  You do NOT need to run a tool for these — read the diff and apply the named fix.
126
139
  If none apply, say so in one line.
@@ -110,7 +110,7 @@ body="$(expand_agent_paths "$body")"
110
110
  # wrong diff. Reset its prior to the Anchor Set, not to its previous attempt.
111
111
  reentry_line="
112
112
 
113
- RE-ENTRY RULE (Regla R1): if a gate or axis failed, forget the approach that produced it. Re-read your ORIGINAL REQUEST above and your Anchor Set (.scope.json, if you wrote one). Fix ONLY what is failing. Do not refactor in this pass - that is History Propagation, the exact failure mode the Anchor Set exists to prevent.
113
+ RE-ENTRY RULE (Regla R1): if a gate or axis failed, forget the approach that produced it. Re-read your ORIGINAL REQUEST above and your Anchor Set (.scope.json, maintained by the intent-anchor hook). Fix ONLY what is failing. Do not refactor in this pass - that is History Propagation, the exact failure mode the Anchor Set exists to prevent.
114
114
  "
115
115
 
116
116
  file_list=""
@@ -58,6 +58,16 @@ pending_dir="$(hooks_pending_dir)"
58
58
  latch="$pending_dir/intent-injected-$cid.flag"
59
59
  hash_file="$pending_dir/last-query-$cid.hash"
60
60
 
61
+ # Stale-latch defense: if a previous session died mid-turn without hitting
62
+ # stop (Cursor crash, force-quit), the latch can persist and silence this hook
63
+ # for the whole next session -> scope never gets created. If the latch is older
64
+ # than 2 hours, treat it as orphaned and clear it. Normal clears happen at
65
+ # every stop (final-review.sh); this is the backstop for abnormal terminations.
66
+ if [ -f "$latch" ]; then
67
+ age_hours=$(( ($(date +%s) - $(stat -c %Y "$latch" 2>/dev/null || stat -f %m "$latch" 2>/dev/null || echo 0)) / 3600 ))
68
+ [ "$age_hours" -ge 2 ] && rm -f "$latch" 2>/dev/null
69
+ fi
70
+
61
71
  # Already injected this turn -> quiet. Latch cleared at every stop.
62
72
  [ -f "$latch" ] && exit 0
63
73
 
@@ -101,7 +111,9 @@ scope_exists=0
101
111
  scope_intent=""
102
112
  scope_acceptance=""
103
113
  scope_files=""
104
- scope_stale=0 # 1 if on-disk contract predates the current query
114
+ scope_stale=0 # 1 when the on-disk contract belongs to a DIFFERENT prompt -> regenerate (resets files[])
115
+ needs_heal=0 # 1 when a model-written contract matches THIS prompt but lacks _intent_hash -> backfill in place
116
+ on_disk_hash=""
105
117
  scope_path="$root/.scope.json"
106
118
  if [ -f "$scope_path" ]; then
107
119
  # Prefer jq; fall back to python3 (mirrors hook-common.sh degrade policy).
@@ -127,42 +139,68 @@ except Exception:
127
139
  EOF
128
140
  [ $? -eq 0 ] && scope_exists=1 || scope_exists=0
129
141
  fi
130
- # Stale if we have a query AND the on-disk _intent_hash differs from it.
131
- if [ "$scope_exists" = "1" ] && [ "$has_query" = "1" ] && [ -n "$on_disk_hash" ]; then
132
- [ "$on_disk_hash" != "$current_hash" ] && scope_stale=1
142
+ # Staleness, hash-agnostic so it survives MODEL-written contracts:
143
+ # - hook-written (has _intent_hash): stale when that hash != current query hash.
144
+ # - model-written (no _intent_hash - the legacy pre-compile.md schema): fall back to
145
+ # $prompt_changed (current query hash != the per-conversation last-query hash). Prompt
146
+ # changed (or a new session) => regenerate and RESET files[] (the "arrastre entre
147
+ # features" fix). Same prompt this session => heal in place (backfill bookkeeping, keep
148
+ # files[]/acceptance) so the NEXT prompt is detected by hash.
149
+ if [ "$scope_exists" = "1" ] && [ "$has_query" = "1" ]; then
150
+ if [ -n "$on_disk_hash" ]; then
151
+ [ "$on_disk_hash" != "$current_hash" ] && scope_stale=1
152
+ elif [ "$prompt_changed" = "1" ]; then
153
+ scope_stale=1
154
+ else
155
+ needs_heal=1
156
+ fi
133
157
  fi
134
158
  fi
135
159
 
136
- # --- auto-create / regenerate .scope.json (the 0.4.4 behavior, fixed) --------
137
- # The user wants: every NEW prompt -> a fresh .scope.json the agent works from.
138
- # So we WRITE the scaffold when (a) there is no valid contract, OR (b) the
139
- # contract on disk is stale (its _intent_hash != current query hash). Fixed vs
140
- # 0.4.4: never writes to $HOME (bail above if no real root) -> no ghost files;
141
- # regenerates on prompt CHANGE not just absence -> "each prompt, new file".
160
+ # --- auto-create / regenerate / heal .scope.json ----------------------------
161
+ # CREATION does NOT require the query: if there's a root and no scope yet,
162
+ # scaffold it NOW with intent=<TODO> (the agent fills it from the chat it's
163
+ # already responding to). This was the 0.5.3 bug - creation was gated on
164
+ # $hasQuery, so when Cursor didn't surface transcript_path in the first
165
+ # postToolUse fire, the scope never got created.
166
+ # REGENERATION requires the query: a prompt change is only detectable when we
167
+ # can read the request. A fresh scaffold resets files[] -> ".scope fresco por
168
+ # prompt, sin arrastre entre features."
142
169
  regenerated=0
143
- should_write=0
144
- if [ "$has_query" = "1" ]; then
145
- if [ "$scope_exists" != "1" ] || [ "$scope_stale" = "1" ]; then
146
- should_write=1
147
- fi
170
+ should_create=0
171
+ should_regen=0
172
+ [ "$scope_exists" != "1" ] && should_create=1
173
+ if [ "$has_query" = "1" ] && [ "$scope_exists" = "1" ] && [ "$scope_stale" = "1" ]; then
174
+ should_regen=1
148
175
  fi
176
+ now_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
149
177
 
150
- if [ "$should_write" = "1" ]; then
151
- # jq preferred; python3 fallback. Write intent from the query, TODO
152
- # placeholders for files/acceptance, and record _intent_hash so staleness
153
- # is self-contained in the file (survives cross-session hash sweeps).
178
+ if [ "$should_create" = "1" ] || [ "$should_regen" = "1" ]; then
179
+ # intent from the query when available, else a TODO for the agent to fill.
180
+ # trace.query records the verbatim originating request (provenance); empty
181
+ # when there is no transcript to read it from.
182
+ if [ "$has_query" = "1" ]; then
183
+ intent_val="$current_query"
184
+ trace_query="$current_query"
185
+ else
186
+ intent_val="<TODO: state the operational objective - what is strictly necessary>"
187
+ trace_query=""
188
+ fi
189
+ # jq preferred; python3 fallback. Write intent, empty files[], TODO acceptance,
190
+ # trace provenance, and _intent_hash so staleness is self-contained.
154
191
  if have_jq; then
155
- jq -n --arg intent "$current_query" --arg hash "$current_hash" \
156
- '{intent:$intent, files:[], acceptance:"<TODO: the one deterministic check that decides done>", allow_growth:false, _intent_hash:$hash, _generated_by:"intent-anchor hook"}' \
192
+ jq -n --arg intent "$intent_val" --arg hash "$current_hash" --arg tq "$trace_query" --arg ts "$now_ts" \
193
+ '{intent:$intent, files:[], acceptance:"<TODO: the one deterministic check that decides done>", allow_growth:false, trace:{query:$tq, ts:$ts}, _intent_hash:$hash, _generated_by:"intent-anchor hook"}' \
157
194
  > "$scope_path" 2>/dev/null && regenerated=1
158
195
  elif have_py; then
159
- if I_FILE="$scope_path" I_INTENT="$current_query" I_HASH="$current_hash" python3 -c '
196
+ if I_FILE="$scope_path" I_INTENT="$intent_val" I_HASH="$current_hash" I_TQ="$trace_query" I_TS="$now_ts" python3 -c '
160
197
  import json, os
161
198
  obj = {
162
199
  "intent": os.environ["I_INTENT"],
163
200
  "files": [],
164
201
  "acceptance": "<TODO: the one deterministic check that decides done>",
165
202
  "allow_growth": False,
203
+ "trace": {"query": os.environ["I_TQ"], "ts": os.environ["I_TS"]},
166
204
  "_intent_hash": os.environ["I_HASH"],
167
205
  "_generated_by": "intent-anchor hook",
168
206
  }
@@ -173,7 +211,7 @@ with open(os.environ["I_FILE"], "w", encoding="utf-8") as f:
173
211
  fi
174
212
  fi
175
213
  if [ "$regenerated" = "1" ]; then
176
- scope_intent="$current_query"
214
+ scope_intent="$intent_val"
177
215
  scope_acceptance="<TODO: the one deterministic check that decides done>"
178
216
  scope_files="(auto-tracked - the scope hook records every file you edit)"
179
217
  scope_exists=1
@@ -181,6 +219,35 @@ with open(os.environ["I_FILE"], "w", encoding="utf-8") as f:
181
219
  fi
182
220
  fi
183
221
 
222
+ # HEAL a model-written contract that matches the current prompt but lacks the
223
+ # hook's bookkeeping: backfill _intent_hash + trace + _generated_by IN PLACE,
224
+ # preserving the model's files[] and acceptance. Without this a contract written
225
+ # per the legacy pre-compile.md schema (no _intent_hash) can never go stale, so
226
+ # the next prompt never regenerates - the carryover bug. Healing installs the
227
+ # hash so the next prompt change is detected by hash like any hook contract.
228
+ if [ "$needs_heal" = "1" ] && [ "$regenerated" != "1" ]; then
229
+ if have_jq; then
230
+ healed="$(jq --arg hash "$current_hash" --arg tq "$current_query" --arg ts "$now_ts" \
231
+ '._intent_hash = $hash | .trace //= {query:$tq, ts:$ts} | ._generated_by //= "intent-anchor hook (healed)"' \
232
+ "$scope_path" 2>/dev/null)"
233
+ [ -n "$healed" ] && printf '%s\n' "$healed" > "$scope_path"
234
+ elif have_py; then
235
+ I_FILE="$scope_path" I_HASH="$current_hash" I_TQ="$current_query" I_TS="$now_ts" python3 -c '
236
+ import json, os, sys
237
+ path = os.environ["I_FILE"]
238
+ try:
239
+ d = json.load(open(path, encoding="utf-8"))
240
+ except Exception:
241
+ sys.exit(0)
242
+ d["_intent_hash"] = os.environ["I_HASH"]
243
+ d.setdefault("trace", {"query": os.environ["I_TQ"], "ts": os.environ["I_TS"]})
244
+ d.setdefault("_generated_by", "intent-anchor hook (healed)")
245
+ with open(path, "w", encoding="utf-8") as f:
246
+ json.dump(d, f, ensure_ascii=False, indent=2)
247
+ ' 2>/dev/null
248
+ fi
249
+ fi
250
+
184
251
  # files[] is auto-tracked and starts empty; show something readable until the
185
252
  # scope hook has recorded the first edit.
186
253
  [ -n "$scope_files" ] || scope_files="(none yet - auto-tracked as you edit)"
@@ -29,29 +29,41 @@ Answer these four, terse, in your first response. One phrase each, not prose:
29
29
  specific failing test going green is. If you cannot name one, you do not
30
30
  yet understand the task — ask.
31
31
 
32
- ## Materialize it: .scope.json
33
-
34
- Write the Anchor Set to `.scope.json` in the repo root before editing source.
35
- This is the machine-checkable form — the scope-gate hook audits every edit
36
- against `files[]`, and the final-review axis 0 traces every diff hunk back to
37
- `intent`. An Anchor Set that lives only in your head is not an Anchor Set.
32
+ ## Materialize it: .scope.json (the hook owns this file)
33
+
34
+ The `intent-anchor` hook creates and maintains `.scope.json` in the repo root for
35
+ you, automatically, on the first tool of every turn:
36
+ - `intent` is locked from your current request and REFRESHED when the request
37
+ changes a new prompt regenerates the contract and resets `files[]`, so it
38
+ never carries over between features;
39
+ - `files[]` is auto-recorded — the scope hook appends every file you edit, so
40
+ you never maintain it by hand;
41
+ - `trace`, `_intent_hash`, `_generated_by` are hook bookkeeping (provenance and
42
+ change detection). Leave them alone.
43
+
44
+ Your one job on the contract: set **`acceptance`** — the single deterministic check
45
+ that decides done — which the hook cannot derive. Edit ONLY that field (a targeted
46
+ string replace). Do **NOT** rewrite the whole file: overwriting it drops the hook's
47
+ `_intent_hash`/`trace`, which disables per-prompt regeneration and brings back the
48
+ cross-feature carryover. The scope-gate hook audits every edit against `files[]`,
49
+ and final-review axis 0 traces every diff hunk back to `intent`.
38
50
 
39
51
  ```json
40
52
  {
41
- "intent": "<OBJECTIVE>",
42
- "files": ["<FILES TO TOUCH, repo-relative, glob-friendly>"],
43
- "acceptance": "<DETERMINISTIC SUCCESS>",
44
- "allow_growth": false
53
+ "intent": "<locked from your request by the hook>",
54
+ "files": ["<auto-recorded by the hook as you edit>"],
55
+ "acceptance": "<YOU set this: the deterministic check that decides done>",
56
+ "allow_growth": false,
57
+ "trace": { "query": "<originating request>", "ts": "<when>" },
58
+ "_intent_hash": "<hook bookkeeping>",
59
+ "_generated_by":"intent-anchor hook"
45
60
  }
46
61
  ```
47
62
 
48
- `allow_growth: false` is the default the gate fires on any edit outside
49
- `files[]`. Set it true only if you expect the work to discover new files
50
- (a refactor, a migration) and you will justify each one as it appears.
51
-
52
- No need to write `.scope.json` for trivial one-liners (a typo, a literal).
53
- The declared-editing ladder's rung 1 ("does this need to exist?") governs when
54
- the Anchor Set itself is overkill. When in doubt, write it.
63
+ `allow_growth: false` is the default. If the contract has not appeared yet (the
64
+ hook scaffolds on a tool boundary), just proceed — you do not need to hand-write
65
+ it. The declared-editing ladder's rung 1 ("does this need to exist?") still governs
66
+ trivial one-liners.
55
67
 
56
68
  ## Regla R3 — Authority
57
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursordoctrine",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "Thin self-review hooks for Cursor — the model is the auditor. Pruned + deduplicated: intent-anchor (auto-scaffolded .scope.json per prompt + per-turn re-injection against Salience Dilution), intent-trace final review, unified anti-slop checklist as single source of truth.",
5
5
  "bin": {
6
6
  "cursordoctrine": "bin/cli.mjs"
@@ -8,12 +8,16 @@ declared-scope check (Step C), so the two never disagree on what counts as
8
8
  "in scope". It also surfaces the contract's `intent` and `acceptance` fields
9
9
  so the calling hook can quote them back to the agent.
10
10
 
11
- .scope.json schema (intent + files[] + acceptance + allow_growth):
11
+ .scope.json schema (the intent-anchor hook owns this file; this matcher reads
12
+ only intent/files/acceptance/allow_growth and ignores the hook bookkeeping):
12
13
  {
13
14
  "intent": "one operational sentence of objective",
14
15
  "files": [ "repo-relative globs", ... ],
15
16
  "acceptance": "the deterministic check that decides success",
16
- "allow_growth": false
17
+ "allow_growth": false,
18
+ "trace": { "query": "originating request", "ts": "ISO-8601" },
19
+ "_intent_hash": "hook bookkeeping: sha256 of the request, drives per-prompt regen",
20
+ "_generated_by":"intent-anchor hook"
17
21
  }
18
22
 
19
23
  Pattern support:
@@ -122,5 +122,18 @@ Determinism / purity:
122
122
  - In-place mutation of shared state (arr.push, obj.prop =) when a caller holds
123
123
  a reference -> return new structures ([...arr, x], .map/.filter).
124
124
 
125
+ Logic & structure:
126
+ - Arrow code: >2 levels of nested if/for -> flatten with guard clauses
127
+ (early returns). Code reads top-to-bottom, no deep indent.
128
+ - Switch/if-else bloat: a switch or 5+ if/else branches -> Map/dispatch
129
+ (Record<State, fn>) or the Command pattern.
130
+ - Mixed abstraction (SLAP): a function mixing DB calls + string validation +
131
+ date formatting -> one level of abstraction per function; extract helpers.
132
+ - Primitive obsession: a primitive with business rules (email, userId, chainId)
133
+ passed as a bare string/number across functions -> a named type/value object.
134
+ - Imperative transforms: a `for` loop building an array when the language has
135
+ .map/.filter/.reduce -> use the declarative form; reserve `for` for cases
136
+ map/reduce cannot express.
137
+
125
138
  You do NOT need to run a tool for these — read the diff and apply the named fix.
126
139
  If none apply, say so in one line.
@@ -119,7 +119,7 @@ $body = Expand-AgentPaths $body
119
119
  # Regla R1 (re-entry): if this review pass is a re-audit after a failed gate or
120
120
  # axis, suppress History Propagation - the model must NOT build on its own prior
121
121
  # wrong diff. Reset its prior to the Anchor Set, not to its previous attempt.
122
- $reentryLine = "`n`nRE-ENTRY RULE (Regla R1): if a gate or axis failed, forget the approach that produced it. Re-read your ORIGINAL REQUEST above and your Anchor Set (.scope.json, if you wrote one). Fix ONLY what is failing. Do not refactor in this pass - that is History Propagation, the exact failure mode the Anchor Set exists to prevent.`n"
122
+ $reentryLine = "`n`nRE-ENTRY RULE (Regla R1): if a gate or axis failed, forget the approach that produced it. Re-read your ORIGINAL REQUEST above and your Anchor Set (.scope.json, maintained by the intent-anchor hook). Fix ONLY what is failing. Do not refactor in this pass - that is History Propagation, the exact failure mode the Anchor Set exists to prevent.`n"
123
123
 
124
124
  $resolved = @($edited | ForEach-Object { Resolve-AgentPath $_ })
125
125
  $fileList = ($resolved | Select-Object -First 30) -join "`n "
@@ -14,17 +14,20 @@
14
14
  # at the START of each turn's work, before edits pile up and dilute the
15
15
  # original intent. Works UNCONDITIONALLY - no transcript needed.
16
16
  #
17
- # 2. AUTO-CREATE / REGENERATE .scope.json: when the current <user_query>
18
- # differs from the contract on disk (no contract yet, OR _intent_hash
19
- # mismatch), the hook WRITES a scaffold to the REPO ROOT: intent locked
20
- # from the prompt, files as an EMPTY array (scope-gate-audit.ps1 fills it
21
- # mechanically as the agent edits - the agent never maintains files[] by
22
- # hand), acceptance as a TODO the agent sets. This is the user-requested
23
- # behavior: every new prompt -> a fresh .scope.json the agent works from.
24
- # Fixed vs the broken 0.4.4 build: never writes to $HOME (bails if no real
25
- # root resolves -> no ghost files), regenerates on prompt CHANGE not just
26
- # on absence.
27
- # 3. RE-INJECT on same-prompt turns: when the query is unchanged (contract
17
+ # 2. AUTO-CREATE .scope.json (UNCONDITIONAL on a real root): if no valid
18
+ # contract exists in the repo root, WRITE one now - intent locked from the
19
+ # query when available, otherwise `intent: <TODO>` for the agent to fill.
20
+ # Creation does NOT require transcript_path; only regeneration does. This
21
+ # was the 0.5.3 bug: creation was gated on $hasQuery, so when Cursor didn't
22
+ # surface the transcript on the first postToolUse fire, the scope never
23
+ # appeared and the agent had no contract to work from.
24
+ # 3. REGENERATE on prompt CHANGE: when the current <user_query> hash differs
25
+ # from the contract's _intent_hash, overwrite the scaffold with the new
26
+ # intent + empty files + TODO acceptance. Requires $hasQuery (you can only
27
+ # detect a change if you can read the request). Fixed vs the broken 0.4.4
28
+ # build: never writes to $HOME (bails if no real root resolves -> no
29
+ # ghost files).
30
+ # 4. RE-INJECT on same-prompt turns: when the query is unchanged (contract
28
31
  # already current), the hook re-injects the existing contract into the
29
32
  # feedback bus so it stays in the model's attentional focus each turn.
30
33
  #
@@ -56,6 +59,16 @@ $pendingDir = Get-HooksPendingDir
56
59
  $latch = Join-Path $pendingDir "intent-injected-$cid.flag"
57
60
  $hashFile = Join-Path $pendingDir "last-query-$cid.hash"
58
61
 
62
+ # Stale-latch defense: if a previous session died mid-turn without hitting
63
+ # stop (Cursor crash, force-quit), the latch can persist and silence this hook
64
+ # for the whole next session -> scope never gets created. If the latch is older
65
+ # than 2 hours, treat it as orphaned and clear it. Normal clears happen at
66
+ # every stop (final-review.ps1); this is the backstop for abnormal terminations.
67
+ if (Test-Path $latch) {
68
+ $age = (Get-Date) - (Get-Item $latch).LastWriteTime
69
+ if ($age.TotalHours -ge 2) { Remove-Item $latch -Force -ErrorAction SilentlyContinue }
70
+ }
71
+
59
72
  # Already injected this turn -> quiet. Latch cleared at every stop.
60
73
  if (Test-Path $latch) { exit 0 }
61
74
 
@@ -96,7 +109,9 @@ $scopeExists = $false
96
109
  $scopeIntent = ''
97
110
  $scopeAcceptance = ''
98
111
  $scopeFiles = ''
99
- $scopeStale = $false # true if the on-disk contract predates the current query
112
+ $scopeStale = $false # true when the on-disk contract belongs to a DIFFERENT prompt -> regenerate (resets files[])
113
+ $needsHeal = $false # true when a model-written contract matches THIS prompt but lacks _intent_hash -> backfill in place
114
+ $scopeHasHash = $false
100
115
  $scopePath = Join-Path $root '.scope.json'
101
116
  if (Test-Path -LiteralPath $scopePath) {
102
117
  try {
@@ -106,39 +121,58 @@ if (Test-Path -LiteralPath $scopePath) {
106
121
  if ($sj.files) { $scopeFiles = (@($sj.files) -join ', ') }
107
122
  if ([string]::IsNullOrWhiteSpace($scopeFiles)) { $scopeFiles = '(none yet - auto-tracked as you edit)' }
108
123
  $scopeExists = $true
109
- # The contract is "stale" if its recorded intent hash != current query
110
- # hash. We persist the query hash inside .scope.json under _intent_hash
111
- # so staleness survives even if last-query-<cid>.hash was swept.
112
- if ($hasQuery -and $sj.PSObject.Properties['_intent_hash']) {
113
- $scopeStale = ([string]$sj._intent_hash -ne $currentHash)
124
+ $scopeHasHash = ($sj.PSObject.Properties['_intent_hash'] -and -not [string]::IsNullOrWhiteSpace([string]$sj._intent_hash))
125
+ # Staleness, hash-agnostic so it survives MODEL-written contracts:
126
+ # - hook-written (has _intent_hash): stale when that hash != current query hash.
127
+ # - model-written (no _intent_hash - the legacy pre-compile.md schema): we cannot
128
+ # hash-compare, so fall back to $promptChanged (current query hash != the per-
129
+ # conversation last-query hash). Prompt changed (or a new session) => stale ->
130
+ # regenerate and RESET files[]; this is the "arrastre entre features" fix (a model-
131
+ # written scope could never go stale, so it never refreshed and scope-gate kept
132
+ # appending files across unrelated features). Same prompt this session => the model
133
+ # wrote it for THIS request; heal in place (backfill the bookkeeping, keep its
134
+ # files[]/acceptance) so the NEXT prompt is detected by hash like any hook contract.
135
+ if ($hasQuery) {
136
+ if ($scopeHasHash) {
137
+ $scopeStale = ([string]$sj._intent_hash -ne $currentHash)
138
+ } elseif ($promptChanged) {
139
+ $scopeStale = $true
140
+ } else {
141
+ $needsHeal = $true
142
+ }
114
143
  }
115
144
  } catch { $scopeExists = $false } # malformed JSON -> treat as missing
116
145
  }
117
146
 
118
- # --- auto-create / regenerate .scope.json (the 0.4.4 behavior, fixed) --------
119
- # The user wants: every NEW prompt -> a fresh .scope.json the agent works from.
120
- # So we WRITE the scaffold when (a) there is no valid contract, OR (b) the
121
- # contract on disk is stale (its _intent_hash != current query hash). Intent is
122
- # locked from the current <user_query>; files starts EMPTY (scope-gate-audit
123
- # auto-records edits into it); acceptance is a TODO the agent sets. Fixed vs 0.4.4:
124
- # - NEVER writes to $HOME (bail above if no real root) -> no ghost files.
125
- # - Regenerates on prompt CHANGE, not just on absence -> "each prompt, new file".
126
- # - Records _intent_hash so staleness is self-contained in the file.
147
+ # --- auto-create / regenerate / heal .scope.json ----------------------------
148
+ # CREATION does NOT require the query: if there's a root and no scope yet,
149
+ # scaffold it NOW with intent=<TODO> (the agent fills it from the chat it's
150
+ # already responding to). This was the 0.5.3 bug - creation was gated on
151
+ # $hasQuery, so when Cursor didn't surface transcript_path in the first
152
+ # postToolUse fire, the scope never got created. The agent never had a
153
+ # contract to work from.
154
+ # REGENERATION requires the query: a prompt change is only detectable when we
155
+ # can read the request. A fresh scaffold resets files[] -> ".scope fresco por
156
+ # prompt, sin arrastre entre features."
127
157
  $regenerated = $false
128
- $shouldWrite = $hasQuery -and (-not $scopeExists -or $scopeStale)
129
- if ($shouldWrite) {
158
+ $shouldCreate = -not $scopeExists
159
+ $shouldRegen = $hasQuery -and $scopeExists -and $scopeStale
160
+ if ($shouldCreate -or $shouldRegen) {
130
161
  try {
162
+ $intentVal = if ($hasQuery) { $currentQuery } else { '<TODO: state the operational objective - what is strictly necessary>' }
163
+ $traceQuery = if ($hasQuery) { $currentQuery } else { '' }
131
164
  $scaffold = [ordered]@{
132
- intent = $currentQuery
165
+ intent = $intentVal
133
166
  files = @()
134
167
  acceptance = '<TODO: the one deterministic check that decides done>'
135
168
  allow_growth = $false
169
+ trace = [ordered]@{ query = $traceQuery; ts = (Get-Date).ToString('o') }
136
170
  _intent_hash = $currentHash
137
171
  _generated_by = 'intent-anchor hook'
138
172
  }
139
- $json = $scaffold | ConvertTo-Json -Depth 5
173
+ $json = $scaffold | ConvertTo-Json -Depth 8
140
174
  [System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
141
- $scopeIntent = $currentQuery
175
+ $scopeIntent = $intentVal
142
176
  $scopeAcceptance = '<TODO: the one deterministic check that decides done>'
143
177
  $scopeFiles = '(auto-tracked - the scope hook records every file you edit)'
144
178
  $scopeExists = $true
@@ -147,6 +181,26 @@ if ($shouldWrite) {
147
181
  } catch { } # write failed (perms / locked) -> fall through to demand msg
148
182
  }
149
183
 
184
+ # HEAL a model-written contract that matches the current prompt but lacks the
185
+ # hook's bookkeeping: backfill _intent_hash + trace + _generated_by IN PLACE,
186
+ # preserving the model's files[] and acceptance. Without this, a contract written
187
+ # per pre-compile.md (no _intent_hash) can never go stale, so the next prompt
188
+ # never regenerates - the carryover bug. Healing installs the hash so the next
189
+ # prompt change is detected by hash like any hook-written contract.
190
+ if ($needsHeal -and -not $regenerated) {
191
+ try {
192
+ $ordered = [ordered]@{}
193
+ foreach ($p in $sj.PSObject.Properties) { $ordered[$p.Name] = $p.Value }
194
+ if (-not $ordered.Contains('trace')) {
195
+ $ordered['trace'] = [ordered]@{ query = $currentQuery; ts = (Get-Date).ToString('o') }
196
+ }
197
+ $ordered['_intent_hash'] = $currentHash
198
+ if (-not $ordered.Contains('_generated_by')) { $ordered['_generated_by'] = 'intent-anchor hook (healed)' }
199
+ $json = $ordered | ConvertTo-Json -Depth 8
200
+ [System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
201
+ } catch { }
202
+ }
203
+
150
204
  # --- compose the anchor message ---------------------------------------------
151
205
  # Three states: regenerated this turn (new prompt), no contract (and no query
152
206
  # to scaffold from), or re-injecting an existing current contract.
@@ -29,29 +29,41 @@ Answer these four, terse, in your first response. One phrase each, not prose:
29
29
  specific failing test going green is. If you cannot name one, you do not
30
30
  yet understand the task — ask.
31
31
 
32
- ## Materialize it: .scope.json
33
-
34
- Write the Anchor Set to `.scope.json` in the repo root before editing source.
35
- This is the machine-checkable form — the scope-gate hook audits every edit
36
- against `files[]`, and the final-review axis 0 traces every diff hunk back to
37
- `intent`. An Anchor Set that lives only in your head is not an Anchor Set.
32
+ ## Materialize it: .scope.json (the hook owns this file)
33
+
34
+ The `intent-anchor` hook creates and maintains `.scope.json` in the repo root for
35
+ you, automatically, on the first tool of every turn:
36
+ - `intent` is locked from your current request and REFRESHED when the request
37
+ changes a new prompt regenerates the contract and resets `files[]`, so it
38
+ never carries over between features;
39
+ - `files[]` is auto-recorded — the scope hook appends every file you edit, so
40
+ you never maintain it by hand;
41
+ - `trace`, `_intent_hash`, `_generated_by` are hook bookkeeping (provenance and
42
+ change detection). Leave them alone.
43
+
44
+ Your one job on the contract: set **`acceptance`** — the single deterministic check
45
+ that decides done — which the hook cannot derive. Edit ONLY that field (a targeted
46
+ string replace). Do **NOT** rewrite the whole file: overwriting it drops the hook's
47
+ `_intent_hash`/`trace`, which disables per-prompt regeneration and brings back the
48
+ cross-feature carryover. The scope-gate hook audits every edit against `files[]`,
49
+ and final-review axis 0 traces every diff hunk back to `intent`.
38
50
 
39
51
  ```json
40
52
  {
41
- "intent": "<OBJECTIVE>",
42
- "files": ["<FILES TO TOUCH, repo-relative, glob-friendly>"],
43
- "acceptance": "<DETERMINISTIC SUCCESS>",
44
- "allow_growth": false
53
+ "intent": "<locked from your request by the hook>",
54
+ "files": ["<auto-recorded by the hook as you edit>"],
55
+ "acceptance": "<YOU set this: the deterministic check that decides done>",
56
+ "allow_growth": false,
57
+ "trace": { "query": "<originating request>", "ts": "<when>" },
58
+ "_intent_hash": "<hook bookkeeping>",
59
+ "_generated_by":"intent-anchor hook"
45
60
  }
46
61
  ```
47
62
 
48
- `allow_growth: false` is the default the gate fires on any edit outside
49
- `files[]`. Set it true only if you expect the work to discover new files
50
- (a refactor, a migration) and you will justify each one as it appears.
51
-
52
- No need to write `.scope.json` for trivial one-liners (a typo, a literal).
53
- The declared-editing ladder's rung 1 ("does this need to exist?") governs when
54
- the Anchor Set itself is overkill. When in doubt, write it.
63
+ `allow_growth: false` is the default. If the contract has not appeared yet (the
64
+ hook scaffolds on a tool boundary), just proceed — you do not need to hand-write
65
+ it. The declared-editing ladder's rung 1 ("does this need to exist?") still governs
66
+ trivial one-liners.
55
67
 
56
68
  ## Regla R3 — Authority
57
69