cursordoctrine 0.5.5 → 0.6.1

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
  });
@@ -1,12 +1,45 @@
1
- Doctrine (governing text) lives at ~/.cursor/doctrine.md and is loaded
2
- at sessionStart. Read it once, internalize it; do not re-read it
3
- mid-task. Its §1 (auditor), §2 (smallest correct diff), §3 (verify
4
- then stop), §5 (ask don't guess), §8 (consistency anchor) are the only
5
- meta-instructions that matter during a session.
6
-
7
- When responding: be terse. No preamble, no postamble, no "I will now…".
8
- One sharp clarifying question if the task is ambiguous, then proceed.
9
- Reference code with `file_path:line_number` style.
10
-
11
- Do not re-load skills, do not re-read the doctrine, do not run
12
- gratuitous commands. If the answer is "I don't know", say so.
1
+ The user is a senior engineer who reviews every diff before shipping.
2
+
3
+ ## Scope
4
+ Change only what the task requires. Preserve existing style and behavior unless
5
+ the task itself is a behavior change. Refactors, renames, cleanup only when
6
+ asked. Leave generated files alone unless explicitly required.
7
+
8
+ ## Intent contract (.scope.json)
9
+ The harness auto-creates `.scope.json` in the repo root on your first tool of
10
+ each turn, and re-injects it into your context every turn. Treat it as your
11
+ operating contract, not optional:
12
+ - On a fresh scaffold, FILL the `intent` and `acceptance` TODOs from the user's
13
+ request before editing source. `files[]` is auto-tracked - do not maintain it.
14
+ - When the user's request changes, the scaffold regenerates with a new intent -
15
+ refill it for the new ask.
16
+ - If a hook surfaces the contract, defer to it: it outranks momentum. Edit
17
+ inside the declared scope; if you must grow it, justify it, don't sneak past.
18
+
19
+ ## Loop
20
+ 1. Read what you need to understand the task.
21
+ 2. Make the minimal correct edit.
22
+ 3. Review the diff. Fix real issues: broken logic, type errors, unsafe
23
+ behavior, data-loss risk, unrequested API/contract changes, regressions.
24
+ Style and naming taste are not bugs.
25
+ 4. Verify proportionally to risk - relevant tests/typechecks for behavior, type,
26
+ API, DB, build, or config changes; nothing for trivial text edits.
27
+ 5. Report what changed and what was verified. Stop.
28
+
29
+ ## Shell
30
+ Run the smallest command that answers the question. Never print secrets,
31
+ tokens, private keys, or sensitive env vars. Never `curl | sh`, force-push, or
32
+ publish without explicit instruction.
33
+
34
+ ## Uncertainty
35
+ If ambiguity affects correctness or safety, ask one sharp question. If
36
+ low-risk, state the assumption and proceed. If a tool returns nothing, say what
37
+ you didn't find - don't fabricate. After two failed attempts at the same
38
+ problem, stop and report observations.
39
+
40
+ ## Commits
41
+ Conventional commits: `type(scope): description`. One logical change per
42
+ commit, small and reviewable. Body only when the why isn't obvious from the
43
+ diff. Verify before pushing when applicable. Never push without explicit
44
+ instruction.
45
+
@@ -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=""
@@ -15,16 +15,19 @@
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. 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 as an EMPTY array (scope-gate-audit.sh fills it
22
- # mechanically as the agent edits - the agent never maintains files[] by
23
- # hand), acceptance as a TODO the agent sets. This is the user-requested
24
- # behavior: every new prompt -> a fresh .scope.json the agent works from.
25
- # Fixed vs the broken 0.4.4 build: never writes to $HOME (bails if no real
26
- # root resolves -> no ghost files), regenerates on prompt CHANGE not just
27
- # on absence.
18
+ # 2. AUTO-CREATE / REGENERATE .scope.json (only when the request is READABLE):
19
+ # when the current <user_query> differs from the contract on disk (no
20
+ # contract yet, _intent_hash mismatch, OR a hollow <TODO> placeholder), the
21
+ # hook WRITES a scaffold to the REPO ROOT: intent locked from the prompt,
22
+ # files as an EMPTY array (scope-gate-audit.sh fills it mechanically as the
23
+ # agent edits - the agent never maintains files[] by hand), acceptance as a
24
+ # TODO the agent sets. We NEVER persist a hollow `intent: <TODO>` file: that
25
+ # caused "el .scope.json se escribe solo sin nada" - when transcript_path is
26
+ # absent on postToolUse the hook can't read the request, so a placeholder
27
+ # with an empty _intent_hash got written, looked owned, and never gained the
28
+ # real intent. Unreadable request -> write nothing, emit the pre-compile
29
+ # demand so the AGENT authors the contract. Never writes to $HOME (bails if
30
+ # no real root resolves -> no ghost files).
28
31
  # 3. RE-INJECT on same-prompt turns: when the query is unchanged (contract
29
32
  # already current), the hook re-injects the existing contract into the
30
33
  # feedback bus so it stays in the model's attentional focus each turn.
@@ -111,7 +114,10 @@ scope_exists=0
111
114
  scope_intent=""
112
115
  scope_acceptance=""
113
116
  scope_files=""
114
- scope_stale=0 # 1 if on-disk contract predates the current query
117
+ scope_stale=0 # 1 when the on-disk contract belongs to a DIFFERENT prompt -> regenerate (resets files[])
118
+ needs_heal=0 # 1 when a model-written contract matches THIS prompt but lacks _intent_hash -> backfill in place
119
+ scope_hollow=0 # 1 when the on-disk contract has no real intent (empty or a <TODO> placeholder) -> unusable
120
+ on_disk_hash=""
115
121
  scope_path="$root/.scope.json"
116
122
  if [ -f "$scope_path" ]; then
117
123
  # Prefer jq; fall back to python3 (mirrors hook-common.sh degrade policy).
@@ -137,50 +143,72 @@ except Exception:
137
143
  EOF
138
144
  [ $? -eq 0 ] && scope_exists=1 || scope_exists=0
139
145
  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
146
+ # Staleness, hash-agnostic so it survives MODEL-written contracts:
147
+ # - hook-written (has _intent_hash): stale when that hash != current query hash.
148
+ # - model-written (no _intent_hash - the legacy pre-compile.md schema): fall back to
149
+ # $prompt_changed (current query hash != the per-conversation last-query hash). Prompt
150
+ # changed (or a new session) => regenerate and RESET files[] (the "arrastre entre
151
+ # features" fix). Same prompt this session => heal in place (backfill bookkeeping, keep
152
+ # files[]/acceptance) so the NEXT prompt is detected by hash.
153
+ # Hollow = no real intent on disk: empty, or still the hook's <TODO> placeholder.
154
+ # A hollow contract is worse than none (it looks owned, so neither hook nor agent
155
+ # fills it). Treat it as unusable: regenerate when the request is readable, else
156
+ # hand the agent the pre-compile demand to author a real one.
157
+ case "$scope_intent" in
158
+ ""|"<TODO"*) scope_hollow=1 ;;
159
+ esac
160
+ if [ "$scope_exists" = "1" ] && [ "$has_query" = "1" ]; then
161
+ if [ "$scope_hollow" = "1" ]; then
162
+ scope_stale=1
163
+ elif [ -n "$on_disk_hash" ]; then
164
+ [ "$on_disk_hash" != "$current_hash" ] && scope_stale=1
165
+ elif [ "$prompt_changed" = "1" ]; then
166
+ scope_stale=1
167
+ else
168
+ needs_heal=1
169
+ fi
143
170
  fi
144
171
  fi
145
172
 
146
- # --- auto-create / regenerate .scope.json -----------------------------------
147
- # CREATION does NOT require the query: if there's a root and no scope yet,
148
- # scaffold it NOW with intent=<TODO> (the agent fills it from the chat it's
149
- # already responding to). This was the 0.5.3 bug - creation was gated on
150
- # $hasQuery, so when Cursor didn't surface transcript_path in the first
151
- # 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.
173
+ # --- auto-create / regenerate / heal .scope.json ----------------------------
174
+ # CREATION and REGENERATION both REQUIRE the query. We only ever write a
175
+ # contract whose intent we actually know - never a hollow <TODO> scaffold.
176
+ # Persisting a placeholder (the 0.5.3 "unconditional creation") caused "el
177
+ # .scope.json se escribe solo sin nada": no transcript_path on postToolUse ->
178
+ # the hook can't read the request -> intent=<TODO> with empty _intent_hash, a
179
+ # file that looks owned and never gains the real intent. Unreadable request ->
180
+ # write nothing, emit the pre-compile demand so the AGENT authors the contract.
181
+ # A fresh write resets files[] -> ".scope fresco por prompt, sin arrastre."
182
+ # (Hollow on-disk contracts are folded into $scope_stale above, so a readable
183
+ # request also overwrites them here.)
155
184
  regenerated=0
156
185
  should_create=0
157
186
  should_regen=0
158
- [ "$scope_exists" != "1" ] && should_create=1
187
+ [ "$scope_exists" != "1" ] && [ "$has_query" = "1" ] && should_create=1
159
188
  if [ "$has_query" = "1" ] && [ "$scope_exists" = "1" ] && [ "$scope_stale" = "1" ]; then
160
189
  should_regen=1
161
190
  fi
191
+ now_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
162
192
 
163
193
  if [ "$should_create" = "1" ] || [ "$should_regen" = "1" ]; then
164
- # intent from the query when available, else a TODO for the agent to fill.
165
- if [ "$has_query" = "1" ]; then
166
- intent_val="$current_query"
167
- else
168
- intent_val="<TODO: state the operational objective - what is strictly necessary>"
169
- fi
170
- # jq preferred; python3 fallback. Write intent, empty files[], TODO
171
- # acceptance, and record _intent_hash so staleness is self-contained.
194
+ # Both paths require $has_query, so intent is always locked from the request.
195
+ intent_val="$current_query"
196
+ trace_query="$current_query"
197
+ # jq preferred; python3 fallback. Write intent, empty files[], TODO acceptance,
198
+ # trace provenance, and _intent_hash so staleness is self-contained.
172
199
  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"}' \
200
+ jq -n --arg intent "$intent_val" --arg hash "$current_hash" --arg tq "$trace_query" --arg ts "$now_ts" \
201
+ '{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
202
  > "$scope_path" 2>/dev/null && regenerated=1
176
203
  elif have_py; then
177
- if I_FILE="$scope_path" I_INTENT="$intent_val" I_HASH="$current_hash" python3 -c '
204
+ if I_FILE="$scope_path" I_INTENT="$intent_val" I_HASH="$current_hash" I_TQ="$trace_query" I_TS="$now_ts" python3 -c '
178
205
  import json, os
179
206
  obj = {
180
207
  "intent": os.environ["I_INTENT"],
181
208
  "files": [],
182
209
  "acceptance": "<TODO: the one deterministic check that decides done>",
183
210
  "allow_growth": False,
211
+ "trace": {"query": os.environ["I_TQ"], "ts": os.environ["I_TS"]},
184
212
  "_intent_hash": os.environ["I_HASH"],
185
213
  "_generated_by": "intent-anchor hook",
186
214
  }
@@ -199,6 +227,35 @@ with open(os.environ["I_FILE"], "w", encoding="utf-8") as f:
199
227
  fi
200
228
  fi
201
229
 
230
+ # HEAL a model-written contract that matches the current prompt but lacks the
231
+ # hook's bookkeeping: backfill _intent_hash + trace + _generated_by IN PLACE,
232
+ # preserving the model's files[] and acceptance. Without this a contract written
233
+ # per the legacy pre-compile.md schema (no _intent_hash) can never go stale, so
234
+ # the next prompt never regenerates - the carryover bug. Healing installs the
235
+ # hash so the next prompt change is detected by hash like any hook contract.
236
+ if [ "$needs_heal" = "1" ] && [ "$regenerated" != "1" ]; then
237
+ if have_jq; then
238
+ healed="$(jq --arg hash "$current_hash" --arg tq "$current_query" --arg ts "$now_ts" \
239
+ '._intent_hash = $hash | .trace //= {query:$tq, ts:$ts} | ._generated_by //= "intent-anchor hook (healed)"' \
240
+ "$scope_path" 2>/dev/null)"
241
+ [ -n "$healed" ] && printf '%s\n' "$healed" > "$scope_path"
242
+ elif have_py; then
243
+ I_FILE="$scope_path" I_HASH="$current_hash" I_TQ="$current_query" I_TS="$now_ts" python3 -c '
244
+ import json, os, sys
245
+ path = os.environ["I_FILE"]
246
+ try:
247
+ d = json.load(open(path, encoding="utf-8"))
248
+ except Exception:
249
+ sys.exit(0)
250
+ d["_intent_hash"] = os.environ["I_HASH"]
251
+ d.setdefault("trace", {"query": os.environ["I_TQ"], "ts": os.environ["I_TS"]})
252
+ d.setdefault("_generated_by", "intent-anchor hook (healed)")
253
+ with open(path, "w", encoding="utf-8") as f:
254
+ json.dump(d, f, ensure_ascii=False, indent=2)
255
+ ' 2>/dev/null
256
+ fi
257
+ fi
258
+
202
259
  # files[] is auto-tracked and starts empty; show something readable until the
203
260
  # scope hook has recorded the first edit.
204
261
  [ -n "$scope_files" ] || scope_files="(none yet - auto-tracked as you edit)"
@@ -224,17 +281,25 @@ is locked from what you just asked. files[] is AUTO-TRACKED - the scope hook
224
281
  records every file you edit, so do not maintain it by hand. Set acceptance to
225
282
  the one deterministic check that decides done, THEN proceed. This contract will
226
283
  be re-injected every turn until your request changes again."
227
- elif [ "$scope_exists" != "1" ]; then
228
- msg="INTENT ANCHOR (pre-compile) - no .scope.json found in $root, and the current
229
- request was unavailable to scaffold from.
284
+ elif [ "$scope_exists" != "1" ] || [ "$scope_hollow" = "1" ]; then
285
+ if [ "$scope_hollow" = "1" ]; then
286
+ state="the .scope.json in $root is only a <TODO> placeholder (the hook could not read your request to fill it)"
287
+ else
288
+ state="no .scope.json found in $root, and the current request was unavailable to scaffold from"
289
+ fi
290
+ msg="INTENT ANCHOR (pre-compile) - $state.
230
291
 
231
292
  Current request:
232
293
  $query_line
233
294
 
234
- Write .scope.json in the repo root yourself:
235
- intent: one operational sentence (what is strictly necessary)
236
- files: the exact files you will touch
237
- acceptance: the one deterministic check that decides done"
295
+ YOU write the real contract to $scope_path now, from THIS conversation, BEFORE
296
+ editing source. Do not leave the <TODO> placeholder:
297
+ intent: one operational sentence - the ACTUAL request (not \"<TODO>\")
298
+ acceptance: the one deterministic check that decides done
299
+ files: [] (leave empty - the scope hook records every file you edit)
300
+
301
+ This is the one case where you own the file: once intent is real, the hook
302
+ takes over (re-injection + per-prompt regeneration)."
238
303
  else
239
304
  # Contract exists and matches the current prompt -> re-inject it.
240
305
  if [ "$has_query" = "1" ]; then
@@ -29,29 +29,52 @@ 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.
67
+
68
+ **Exception a hollow contract is YOURS to write.** The hook can only lock
69
+ `intent` when the harness surfaces your request to it; in some Cursor builds it
70
+ cannot, and the `intent-anchor` hook will then ask YOU to author the contract (or
71
+ you may open `.scope.json` and find `intent` still a `<TODO>` placeholder). In
72
+ that one case, write the whole file yourself from this conversation — a real
73
+ `intent` (the actual request, not `<TODO>`), `acceptance`, and `files: []` — and
74
+ do it BEFORE editing source. Never leave a `<TODO>` intent on disk: a placeholder
75
+ contract looks owned, so nothing ever fills it, and scope-gate/final-review then
76
+ audit your diff against nothing. Once `intent` is real, hand the file back to the
77
+ hook (re-injection + per-prompt regeneration take over).
55
78
 
56
79
  ## Regla R3 — Authority
57
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursordoctrine",
3
- "version": "0.5.5",
3
+ "version": "0.6.1",
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:
@@ -1,12 +1,45 @@
1
- Doctrine (governing text) lives at ~/.cursor/doctrine.md and is loaded
2
- at sessionStart. Read it once, internalize it; do not re-read it
3
- mid-task. Its §1 (auditor), §2 (smallest correct diff), §3 (verify
4
- then stop), §5 (ask don't guess), §8 (consistency anchor) are the only
5
- meta-instructions that matter during a session.
6
-
7
- When responding: be terse. No preamble, no postamble, no "I will now…".
8
- One sharp clarifying question if the task is ambiguous, then proceed.
9
- Reference code with `file_path:line_number` style.
10
-
11
- Do not re-load skills, do not re-read the doctrine, do not run
12
- gratuitous commands. If the answer is "I don't know", say so.
1
+ The user is a senior engineer who reviews every diff before shipping.
2
+
3
+ ## Scope
4
+ Change only what the task requires. Preserve existing style and behavior unless
5
+ the task itself is a behavior change. Refactors, renames, cleanup only when
6
+ asked. Leave generated files alone unless explicitly required.
7
+
8
+ ## Intent contract (.scope.json)
9
+ The harness auto-creates `.scope.json` in the repo root on your first tool of
10
+ each turn, and re-injects it into your context every turn. Treat it as your
11
+ operating contract, not optional:
12
+ - On a fresh scaffold, FILL the `intent` and `acceptance` TODOs from the user's
13
+ request before editing source. `files[]` is auto-tracked - do not maintain it.
14
+ - When the user's request changes, the scaffold regenerates with a new intent -
15
+ refill it for the new ask.
16
+ - If a hook surfaces the contract, defer to it: it outranks momentum. Edit
17
+ inside the declared scope; if you must grow it, justify it, don't sneak past.
18
+
19
+ ## Loop
20
+ 1. Read what you need to understand the task.
21
+ 2. Make the minimal correct edit.
22
+ 3. Review the diff. Fix real issues: broken logic, type errors, unsafe
23
+ behavior, data-loss risk, unrequested API/contract changes, regressions.
24
+ Style and naming taste are not bugs.
25
+ 4. Verify proportionally to risk - relevant tests/typechecks for behavior, type,
26
+ API, DB, build, or config changes; nothing for trivial text edits.
27
+ 5. Report what changed and what was verified. Stop.
28
+
29
+ ## Shell
30
+ Run the smallest command that answers the question. Never print secrets,
31
+ tokens, private keys, or sensitive env vars. Never `curl | sh`, force-push, or
32
+ publish without explicit instruction.
33
+
34
+ ## Uncertainty
35
+ If ambiguity affects correctness or safety, ask one sharp question. If
36
+ low-risk, state the assumption and proceed. If a tool returns nothing, say what
37
+ you didn't find - don't fabricate. After two failed attempts at the same
38
+ problem, stop and report observations.
39
+
40
+ ## Commits
41
+ Conventional commits: `type(scope): description`. One logical change per
42
+ commit, small and reviewable. Body only when the why isn't obvious from the
43
+ diff. Verify before pushing when applicable. Never push without explicit
44
+ instruction.
45
+
@@ -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,18 +14,22 @@
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 .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
17
+ # 2. AUTO-CREATE .scope.json (only when the request is READABLE): if no valid
18
+ # contract exists and we can read <user_query>, WRITE one now with intent
19
+ # locked from the query. We NEVER persist a hollow `intent: <TODO>` file:
20
+ # that 0.5.3 "unconditional creation" caused "el .scope.json se escribe
21
+ # solo sin nada" - when Cursor doesn't surface transcript_path on
22
+ # postToolUse the hook can't read the request, so it wrote a placeholder
23
+ # with an empty _intent_hash. The file then looks owned (pre-compile.md
24
+ # tells the agent to leave it alone) and never gets the real intent. When
25
+ # the request is unreadable we write nothing and emit the pre-compile
26
+ # demand so the AGENT authors a real contract from the chat it already has.
27
+ # 3. REGENERATE on prompt CHANGE or HOLLOW contract: when the current
28
+ # <user_query> hash differs from the contract's _intent_hash, OR the
29
+ # on-disk contract has no real intent (empty / <TODO> placeholder),
30
+ # overwrite it with the new intent + empty files + TODO acceptance.
31
+ # Requires $hasQuery (you can only lock intent if you can read the
32
+ # request). Never writes to $HOME (bails if no real root resolves -> no
29
33
  # ghost files).
30
34
  # 4. RE-INJECT on same-prompt turns: when the query is unchanged (contract
31
35
  # already current), the hook re-injects the existing contract into the
@@ -109,7 +113,10 @@ $scopeExists = $false
109
113
  $scopeIntent = ''
110
114
  $scopeAcceptance = ''
111
115
  $scopeFiles = ''
112
- $scopeStale = $false # true if the on-disk contract predates the current query
116
+ $scopeStale = $false # true when the on-disk contract belongs to a DIFFERENT prompt -> regenerate (resets files[])
117
+ $needsHeal = $false # true when a model-written contract matches THIS prompt but lacks _intent_hash -> backfill in place
118
+ $scopeHasHash = $false
119
+ $scopeHollow = $false # true when the on-disk contract has no real intent (empty or a <TODO> placeholder) -> unusable
113
120
  $scopePath = Join-Path $root '.scope.json'
114
121
  if (Test-Path -LiteralPath $scopePath) {
115
122
  try {
@@ -119,40 +126,68 @@ if (Test-Path -LiteralPath $scopePath) {
119
126
  if ($sj.files) { $scopeFiles = (@($sj.files) -join ', ') }
120
127
  if ([string]::IsNullOrWhiteSpace($scopeFiles)) { $scopeFiles = '(none yet - auto-tracked as you edit)' }
121
128
  $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)
129
+ $scopeHasHash = ($sj.PSObject.Properties['_intent_hash'] -and -not [string]::IsNullOrWhiteSpace([string]$sj._intent_hash))
130
+ # Hollow = no real intent on disk: empty, or still the hook's <TODO> placeholder.
131
+ # A hollow contract is worse than none (it looks owned, so neither hook nor agent
132
+ # fills it; scope-gate appends files to it and final-review audits against <TODO>).
133
+ # Treat it as unusable: regenerate when we can read the request, else hand the agent
134
+ # the pre-compile demand to write a real one.
135
+ $scopeHollow = ([string]::IsNullOrWhiteSpace($scopeIntent) -or $scopeIntent -match '^\s*<TODO')
136
+ # Staleness, hash-agnostic so it survives MODEL-written contracts:
137
+ # - hook-written (has _intent_hash): stale when that hash != current query hash.
138
+ # - model-written (no _intent_hash - the legacy pre-compile.md schema): we cannot
139
+ # hash-compare, so fall back to $promptChanged (current query hash != the per-
140
+ # conversation last-query hash). Prompt changed (or a new session) => stale ->
141
+ # regenerate and RESET files[]; this is the "arrastre entre features" fix (a model-
142
+ # written scope could never go stale, so it never refreshed and scope-gate kept
143
+ # appending files across unrelated features). Same prompt this session => the model
144
+ # wrote it for THIS request; heal in place (backfill the bookkeeping, keep its
145
+ # files[]/acceptance) so the NEXT prompt is detected by hash like any hook contract.
146
+ if ($hasQuery) {
147
+ if ($scopeHollow) {
148
+ # Hollow + we can read the request -> overwrite with the real intent now.
149
+ $scopeStale = $true
150
+ } elseif ($scopeHasHash) {
151
+ $scopeStale = ([string]$sj._intent_hash -ne $currentHash)
152
+ } elseif ($promptChanged) {
153
+ $scopeStale = $true
154
+ } else {
155
+ $needsHeal = $true
156
+ }
127
157
  }
128
158
  } catch { $scopeExists = $false } # malformed JSON -> treat as missing
129
159
  }
130
160
 
131
- # --- auto-create / regenerate .scope.json -----------------------------------
132
- # CREATION does NOT require the query: if there's a root and no scope yet,
133
- # scaffold it NOW with intent=<TODO> (the agent fills it from the chat it's
134
- # already responding to). This was the 0.5.3 bug - creation was gated on
135
- # $hasQuery, so when Cursor didn't surface transcript_path in the first
136
- # postToolUse fire, the scope never got created. The agent never had a
137
- # 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.
161
+ # --- auto-create / regenerate / heal .scope.json ----------------------------
162
+ # CREATION and REGENERATION both REQUIRE the query. We only ever write a
163
+ # contract whose intent we actually know - never a hollow <TODO> scaffold.
164
+ # Persisting a placeholder file (the 0.5.3 "unconditional creation") was the
165
+ # bug behind "el .scope.json se escribe solo sin nada": when Cursor doesn't
166
+ # surface transcript_path on postToolUse, the hook can't read the request, so
167
+ # it wrote intent=<TODO> with an empty _intent_hash. That file looks owned, so
168
+ # pre-compile.md tells the agent to leave it alone, and it never gets the real
169
+ # intent. When the request is unreadable we now write NOTHING and instead hand
170
+ # the agent the pre-compile demand to author a real contract from the chat it
171
+ # is already responding to. A fresh write resets files[] -> ".scope fresco por
172
+ # prompt, sin arrastre entre features." (Hollow on-disk contracts are folded
173
+ # into $scopeStale above, so a readable request also overwrites them here.)
141
174
  $regenerated = $false
142
- $shouldCreate = -not $scopeExists
175
+ $shouldCreate = (-not $scopeExists) -and $hasQuery
143
176
  $shouldRegen = $hasQuery -and $scopeExists -and $scopeStale
144
177
  if ($shouldCreate -or $shouldRegen) {
145
178
  try {
146
- $intentVal = if ($hasQuery) { $currentQuery } else { '<TODO: state the operational objective - what is strictly necessary>' }
179
+ $intentVal = $currentQuery
180
+ $traceQuery = $currentQuery
147
181
  $scaffold = [ordered]@{
148
182
  intent = $intentVal
149
183
  files = @()
150
184
  acceptance = '<TODO: the one deterministic check that decides done>'
151
185
  allow_growth = $false
186
+ trace = [ordered]@{ query = $traceQuery; ts = (Get-Date).ToString('o') }
152
187
  _intent_hash = $currentHash
153
188
  _generated_by = 'intent-anchor hook'
154
189
  }
155
- $json = $scaffold | ConvertTo-Json -Depth 5
190
+ $json = $scaffold | ConvertTo-Json -Depth 8
156
191
  [System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
157
192
  $scopeIntent = $intentVal
158
193
  $scopeAcceptance = '<TODO: the one deterministic check that decides done>'
@@ -163,6 +198,26 @@ if ($shouldCreate -or $shouldRegen) {
163
198
  } catch { } # write failed (perms / locked) -> fall through to demand msg
164
199
  }
165
200
 
201
+ # HEAL a model-written contract that matches the current prompt but lacks the
202
+ # hook's bookkeeping: backfill _intent_hash + trace + _generated_by IN PLACE,
203
+ # preserving the model's files[] and acceptance. Without this, a contract written
204
+ # per pre-compile.md (no _intent_hash) can never go stale, so the next prompt
205
+ # never regenerates - the carryover bug. Healing installs the hash so the next
206
+ # prompt change is detected by hash like any hook-written contract.
207
+ if ($needsHeal -and -not $regenerated) {
208
+ try {
209
+ $ordered = [ordered]@{}
210
+ foreach ($p in $sj.PSObject.Properties) { $ordered[$p.Name] = $p.Value }
211
+ if (-not $ordered.Contains('trace')) {
212
+ $ordered['trace'] = [ordered]@{ query = $currentQuery; ts = (Get-Date).ToString('o') }
213
+ }
214
+ $ordered['_intent_hash'] = $currentHash
215
+ if (-not $ordered.Contains('_generated_by')) { $ordered['_generated_by'] = 'intent-anchor hook (healed)' }
216
+ $json = $ordered | ConvertTo-Json -Depth 8
217
+ [System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
218
+ } catch { }
219
+ }
220
+
166
221
  # --- compose the anchor message ---------------------------------------------
167
222
  # Three states: regenerated this turn (new prompt), no contract (and no query
168
223
  # to scaffold from), or re-injecting an existing current contract.
@@ -182,18 +237,22 @@ records every file you edit, so do not maintain it by hand. Set acceptance to
182
237
  the one deterministic check that decides done, THEN proceed. This contract will
183
238
  be re-injected every turn until your request changes again.
184
239
  "@
185
- } elseif (-not $scopeExists) {
240
+ } elseif (-not $scopeExists -or $scopeHollow) {
241
+ $state = if ($scopeHollow) { "the .scope.json in $root is only a <TODO> placeholder (the hook could not read your request to fill it)" } else { "no .scope.json found in $root, and the current request was unavailable to scaffold from" }
186
242
  $msg = @"
187
- INTENT ANCHOR (pre-compile) - no .scope.json found in $root, and the current
188
- request was unavailable to scaffold from.
243
+ INTENT ANCHOR (pre-compile) - $state.
189
244
 
190
245
  Current request:
191
246
  $queryLine
192
247
 
193
- Write .scope.json in the repo root yourself:
194
- intent: one operational sentence (what is strictly necessary)
195
- files: the exact files you will touch
248
+ YOU write the real contract to $scopePath now, from THIS conversation, BEFORE
249
+ editing source. Do not leave the <TODO> placeholder:
250
+ intent: one operational sentence - the ACTUAL request (not "<TODO>")
196
251
  acceptance: the one deterministic check that decides done
252
+ files: [] (leave empty - the scope hook records every file you edit)
253
+
254
+ This is the one case where you own the file: once intent is real, the hook
255
+ takes over (re-injection + per-prompt regeneration).
197
256
  "@
198
257
  } else {
199
258
  # Contract exists and matches the current prompt -> re-inject it.
@@ -29,29 +29,52 @@ 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.
67
+
68
+ **Exception a hollow contract is YOURS to write.** The hook can only lock
69
+ `intent` when the harness surfaces your request to it; in some Cursor builds it
70
+ cannot, and the `intent-anchor` hook will then ask YOU to author the contract (or
71
+ you may open `.scope.json` and find `intent` still a `<TODO>` placeholder). In
72
+ that one case, write the whole file yourself from this conversation — a real
73
+ `intent` (the actual request, not `<TODO>`), `acceptance`, and `files: []` — and
74
+ do it BEFORE editing source. Never leave a `<TODO>` intent on disk: a placeholder
75
+ contract looks owned, so nothing ever fills it, and scope-gate/final-review then
76
+ audit your diff against nothing. Once `intent` is real, hand the file back to the
77
+ hook (re-injection + per-prompt regeneration take over).
55
78
 
56
79
  ## Regla R3 — Authority
57
80