cursordoctrine 0.5.5 → 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
@@ -408,6 +408,56 @@ function verify() {
408
408
  if (!scope._generated_by || !scope._intent_hash) {
409
409
  cleanup(); return { ok: false, detail: 'scaffold missing _generated_by / _intent_hash fields' };
410
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
+
411
461
  cleanup();
412
462
  return true;
413
463
  });
@@ -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=""
@@ -111,7 +111,9 @@ scope_exists=0
111
111
  scope_intent=""
112
112
  scope_acceptance=""
113
113
  scope_files=""
114
- 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=""
115
117
  scope_path="$root/.scope.json"
116
118
  if [ -f "$scope_path" ]; then
117
119
  # Prefer jq; fall back to python3 (mirrors hook-common.sh degrade policy).
@@ -137,21 +139,33 @@ except Exception:
137
139
  EOF
138
140
  [ $? -eq 0 ] && scope_exists=1 || scope_exists=0
139
141
  fi
140
- # Stale if we have a query AND the on-disk _intent_hash differs from it.
141
- if [ "$scope_exists" = "1" ] && [ "$has_query" = "1" ] && [ -n "$on_disk_hash" ]; then
142
- [ "$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
143
157
  fi
144
158
  fi
145
159
 
146
- # --- auto-create / regenerate .scope.json -----------------------------------
160
+ # --- auto-create / regenerate / heal .scope.json ----------------------------
147
161
  # CREATION does NOT require the query: if there's a root and no scope yet,
148
162
  # scaffold it NOW with intent=<TODO> (the agent fills it from the chat it's
149
163
  # already responding to). This was the 0.5.3 bug - creation was gated on
150
164
  # $hasQuery, so when Cursor didn't surface transcript_path in the first
151
165
  # postToolUse fire, the scope never got created.
152
- # REGENERATION does require the query: we can only detect a prompt change if
153
- # we can hash the current request. Without a query we leave an existing scope
154
- # alone (re-inject it) rather than blank it.
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."
155
169
  regenerated=0
156
170
  should_create=0
157
171
  should_regen=0
@@ -159,28 +173,34 @@ should_regen=0
159
173
  if [ "$has_query" = "1" ] && [ "$scope_exists" = "1" ] && [ "$scope_stale" = "1" ]; then
160
174
  should_regen=1
161
175
  fi
176
+ now_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
162
177
 
163
178
  if [ "$should_create" = "1" ] || [ "$should_regen" = "1" ]; then
164
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.
165
182
  if [ "$has_query" = "1" ]; then
166
183
  intent_val="$current_query"
184
+ trace_query="$current_query"
167
185
  else
168
186
  intent_val="<TODO: state the operational objective - what is strictly necessary>"
187
+ trace_query=""
169
188
  fi
170
- # jq preferred; python3 fallback. Write intent, empty files[], TODO
171
- # acceptance, and record _intent_hash so staleness is self-contained.
189
+ # jq preferred; python3 fallback. Write intent, empty files[], TODO acceptance,
190
+ # trace provenance, and _intent_hash so staleness is self-contained.
172
191
  if have_jq; then
173
- jq -n --arg intent "$intent_val" --arg hash "$current_hash" \
174
- '{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"}' \
175
194
  > "$scope_path" 2>/dev/null && regenerated=1
176
195
  elif have_py; then
177
- if I_FILE="$scope_path" I_INTENT="$intent_val" 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 '
178
197
  import json, os
179
198
  obj = {
180
199
  "intent": os.environ["I_INTENT"],
181
200
  "files": [],
182
201
  "acceptance": "<TODO: the one deterministic check that decides done>",
183
202
  "allow_growth": False,
203
+ "trace": {"query": os.environ["I_TQ"], "ts": os.environ["I_TS"]},
184
204
  "_intent_hash": os.environ["I_HASH"],
185
205
  "_generated_by": "intent-anchor hook",
186
206
  }
@@ -199,6 +219,35 @@ with open(os.environ["I_FILE"], "w", encoding="utf-8") as f:
199
219
  fi
200
220
  fi
201
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
+
202
251
  # files[] is auto-tracked and starts empty; show something readable until the
203
252
  # scope hook has recorded the first edit.
204
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.5",
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:
@@ -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 "
@@ -109,7 +109,9 @@ $scopeExists = $false
109
109
  $scopeIntent = ''
110
110
  $scopeAcceptance = ''
111
111
  $scopeFiles = ''
112
- $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
113
115
  $scopePath = Join-Path $root '.scope.json'
114
116
  if (Test-Path -LiteralPath $scopePath) {
115
117
  try {
@@ -119,40 +121,56 @@ if (Test-Path -LiteralPath $scopePath) {
119
121
  if ($sj.files) { $scopeFiles = (@($sj.files) -join ', ') }
120
122
  if ([string]::IsNullOrWhiteSpace($scopeFiles)) { $scopeFiles = '(none yet - auto-tracked as you edit)' }
121
123
  $scopeExists = $true
122
- # The contract is "stale" if its recorded intent hash != current query
123
- # hash. We persist the query hash inside .scope.json under _intent_hash
124
- # so staleness survives even if last-query-<cid>.hash was swept.
125
- if ($hasQuery -and $sj.PSObject.Properties['_intent_hash']) {
126
- $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
+ }
127
143
  }
128
144
  } catch { $scopeExists = $false } # malformed JSON -> treat as missing
129
145
  }
130
146
 
131
- # --- auto-create / regenerate .scope.json -----------------------------------
147
+ # --- auto-create / regenerate / heal .scope.json ----------------------------
132
148
  # CREATION does NOT require the query: if there's a root and no scope yet,
133
149
  # scaffold it NOW with intent=<TODO> (the agent fills it from the chat it's
134
150
  # already responding to). This was the 0.5.3 bug - creation was gated on
135
151
  # $hasQuery, so when Cursor didn't surface transcript_path in the first
136
152
  # postToolUse fire, the scope never got created. The agent never had a
137
153
  # contract to work from.
138
- # REGENERATION does require the query: we can only detect a prompt change if
139
- # we can hash the current request. Without a query we leave an existing scope
140
- # alone (re-inject it) rather than blank it.
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."
141
157
  $regenerated = $false
142
158
  $shouldCreate = -not $scopeExists
143
159
  $shouldRegen = $hasQuery -and $scopeExists -and $scopeStale
144
160
  if ($shouldCreate -or $shouldRegen) {
145
161
  try {
146
- $intentVal = if ($hasQuery) { $currentQuery } else { '<TODO: state the operational objective - what is strictly necessary>' }
162
+ $intentVal = if ($hasQuery) { $currentQuery } else { '<TODO: state the operational objective - what is strictly necessary>' }
163
+ $traceQuery = if ($hasQuery) { $currentQuery } else { '' }
147
164
  $scaffold = [ordered]@{
148
165
  intent = $intentVal
149
166
  files = @()
150
167
  acceptance = '<TODO: the one deterministic check that decides done>'
151
168
  allow_growth = $false
169
+ trace = [ordered]@{ query = $traceQuery; ts = (Get-Date).ToString('o') }
152
170
  _intent_hash = $currentHash
153
171
  _generated_by = 'intent-anchor hook'
154
172
  }
155
- $json = $scaffold | ConvertTo-Json -Depth 5
173
+ $json = $scaffold | ConvertTo-Json -Depth 8
156
174
  [System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
157
175
  $scopeIntent = $intentVal
158
176
  $scopeAcceptance = '<TODO: the one deterministic check that decides done>'
@@ -163,6 +181,26 @@ if ($shouldCreate -or $shouldRegen) {
163
181
  } catch { } # write failed (perms / locked) -> fall through to demand msg
164
182
  }
165
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
+
166
204
  # --- compose the anchor message ---------------------------------------------
167
205
  # Three states: regenerated this turn (new prompt), no contract (and no query
168
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