cursordoctrine 0.6.3 → 0.6.5

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/INSTALL.md CHANGED
@@ -76,11 +76,13 @@ echo '{"conversation_id":"t1","status":"completed"}' | bash ~/.agents/hooks/fina
76
76
  echo '{"conversation_id":"t2","file_path":"/tmp/x.py"}' | bash ~/.agents/hooks/self-review-trigger.sh
77
77
  echo '{"conversation_id":"t2","status":"completed"}' | bash ~/.agents/hooks/subagent-stop-review.sh # expect {"followup_message": "SUBAGENT FINAL REVIEW ..."} once, then {}
78
78
  echo '{"conversation_id":"t2"}' | bash ~/.agents/hooks/post-tool-use.sh # drain t2's leftover feedback file
79
- # intent-anchor: writes .scope.json from the current prompt and re-injects it.
80
- # Needs a repo root in cwd (it will NOT write to $HOME - that's the guard).
79
+ # intent-precompile: writes .scope.json from the prompt BEFORE the first token.
80
+ # Needs a repo root (workspace_roots/cwd; it will NOT write to $HOME - that's the guard).
81
+ echo '{"conversation_id":"t3","cwd":"/tmp","prompt":"fix the dashboard bug"}' | bash ~/.agents/hooks/intent-precompile.sh
82
+ cat /tmp/.scope.json # expect intent="fix the dashboard bug", a seeded acceptance (no <TODO>)
83
+ # intent-anchor: re-injects the contract (and is the fallback creator).
81
84
  echo '{"conversation_id":"t3","cwd":"/tmp","file_path":"/tmp/x.py"}' | bash ~/.agents/hooks/intent-anchor.sh
82
- cat /tmp/.scope.json # expect a JSON scaffold with intent placeholder
83
- echo '{"conversation_id":"t3"}' | bash ~/.agents/hooks/post-tool-use.sh # expect additional_context with the scaffold
85
+ echo '{"conversation_id":"t3"}' | bash ~/.agents/hooks/post-tool-use.sh # expect additional_context with the contract
84
86
  echo '{}' | bash ~/.cursor/inject-doctrine.sh # expect {"additional_context": ...}
85
87
  python3 ~/.cursor/skills/anti-slop/scripts/scan_slop.py --help # expect usage text (final review's scanner)
86
88
  rm -f /tmp/.scope.json # cleanup the test scaffold
@@ -97,11 +99,13 @@ Windows (same payloads, swap `bash ~/...sh` for `pwsh.exe -NoProfile -File $HOME
97
99
  echo '{"command":"git push --force"}' | pwsh.exe -NoProfile -File $HOME\.agents\hooks\permission-gate.ps1
98
100
  echo '{"conversation_id":"t2","file_path":"C:\tmp\x.py"}' | pwsh.exe -NoProfile -File $HOME\.agents\hooks\self-review-trigger.ps1
99
101
  echo '{"conversation_id":"t2","status":"completed"}' | pwsh.exe -NoProfile -File $HOME\.agents\hooks\subagent-stop-review.ps1 # SUBAGENT FINAL REVIEW once, then {}
100
- # intent-anchor: writes .scope.json from the current prompt and re-injects it.
101
- # Needs a repo root in cwd (it will NOT write to $HOME - that's the guard).
102
+ # intent-precompile: writes .scope.json from the prompt BEFORE the first token.
103
+ # Needs a repo root (workspace_roots/cwd; it will NOT write to $HOME - that's the guard).
104
+ echo '{"conversation_id":"t3","cwd":"C:\tmp","prompt":"fix the dashboard bug"}' | pwsh.exe -NoProfile -File $HOME\.agents\hooks\intent-precompile.ps1
105
+ Get-Content C:\tmp\.scope.json # expect intent="fix the dashboard bug", a seeded acceptance (no <TODO>)
106
+ # intent-anchor: re-injects the contract (and is the fallback creator).
102
107
  echo '{"conversation_id":"t3","cwd":"C:\tmp","file_path":"C:\tmp\x.py"}' | pwsh.exe -NoProfile -File $HOME\.agents\hooks\intent-anchor.ps1
103
- Get-Content C:\tmp\.scope.json # expect a JSON scaffold with intent placeholder
104
- echo '{"conversation_id":"t3"}' | pwsh.exe -NoProfile -File $HOME\.agents\hooks\post-tool-use.ps1 # additional_context with the scaffold
108
+ echo '{"conversation_id":"t3"}' | pwsh.exe -NoProfile -File $HOME\.agents\hooks\post-tool-use.ps1 # additional_context with the contract
105
109
  python $HOME\.cursor\skills\anti-slop\scripts\scan_slop.py --help
106
110
  Remove-Item C:\tmp\.scope.json -Force # cleanup the test scaffold
107
111
  ```
@@ -112,7 +116,7 @@ Also validate the config: `~/.cursor/hooks.json` must parse as JSON.
112
116
 
113
117
  1. Restart Cursor (hooks.json is read at startup).
114
118
  2. Open any project and start a new agent chat. The doctrine should be in context — ask the agent "what does your doctrine say about diffs?" and it should answer from §2; ask "what is the Anchor Set?" and it should answer from `pre-compile.md` (Objective / Constraints / Scope / Deterministic success).
115
- 3. Have the agent make a small edit to a tracked file. On the next turn it should receive a `SELF-REVIEW TRIGGER` message, and `intent-anchor` should have written `.scope.json` to the repo root (intent from your prompt, `<TODO>` placeholders for files/acceptance). The scaffold regenerates when your prompt changes and is re-injected every turn.
119
+ 3. Send a prompt. `.scope.json` should appear in the repo root **immediately** (before the agent's first edit), written by `intent-precompile` with `intent` from your prompt and a seeded `acceptance` (not a bare `<TODO>`). Have the agent make a small edit: on the next turn it should receive a `SELF-REVIEW TRIGGER` message, and `intent-anchor` re-injects the contract. The contract regenerates when your prompt changes.
116
120
  4. Ask the agent to run `git push --force` (in a throwaway repo). The permission gate must block it.
117
121
  5. Finish a small implementation and stop. A single `FINAL REVIEW` follow-up should fire — exactly once.
118
122
  6. Delegate a small edit to a subagent (e.g. ask the agent to "use a generalPurpose subagent to add a comment to <file>"). The subagent should receive one `SUBAGENT FINAL REVIEW` follow-up before returning, and the parent should see `SUBAGENT WORK DETECTED` at its next tool boundary. (`subagentStop` is only read at startup — if nothing fires, restart Cursor again.)
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  <div align="center">
11
11
  <h1>cursordoctrine</h1>
12
12
  <p><strong>Self-review hooks for Cursor — proactive and reactive.</strong></p>
13
- <p>Five hook events, one message bus.<br />The model compiles its intent, audits its own work, and stays on the rails. Cursor carries context and gates blast radius.</p>
13
+ <p>Six hook events, one message bus.<br />The model compiles its intent, audits its own work, and stays on the rails. Cursor carries context and gates blast radius.</p>
14
14
  </div>
15
15
 
16
16
  <br />
@@ -21,7 +21,7 @@
21
21
 
22
22
  Cursor hooks that make the agent review its own edits without bolting a static-analysis pipeline onto every keystroke. No regex army, no scoring engine. Four jobs:
23
23
 
24
- 1. **Compile intent before coding** (proactive) — at session start the agent gets the doctrine plus the **Anchor Set** discipline (`pre-compile.md`): before writing code it must emit *Objective / Constraints / Scope / Deterministic success*. `intent-anchor` materializes that as `.scope.json` on the first tool of each turn (auto-created, regenerated when the prompt changes) and re-injects it into context every turn, so the contract stays in focus against Salience Dilution.
24
+ 1. **Compile intent before coding** (proactive) — at session start the agent gets the doctrine plus the **Anchor Set** discipline (`pre-compile.md`): before writing code it must emit *Objective / Constraints / Scope / Deterministic success*. `intent-precompile` (`beforeSubmitPrompt`) materializes that as `.scope.json` the moment you hit send — **before the agent's first token** with `intent` locked from the request and `acceptance` seeded (never a bare `<TODO>`), so the contract is the first artifact of the turn. `intent-anchor` then re-injects it into context every turn (regenerated when the prompt changes), so it stays in focus against Salience Dilution.
25
25
  2. **Inject the doctrine** at session start — every chat starts with the same short governing text (`doctrine.md`, `USER-RULES.md`, `declared-editing.md` the YAGNI ultra ladder, and `pre-compile.md` the thin intent-compilation phase).
26
26
  3. **Hand the model its own edits back** (reactive) — after each agent edit, a self-review prompt goes into a pending file (plus semantic-density, scope-gate, and anti-slop advisories when they trip). Next turn the model reads its diff, fixes real bugs, stays quiet otherwise.
27
27
  4. **Gate blast radius** — one permission gate denies a short explicit list of dangerous commands (`rm -rf /`, `curl | sh`, force-push, `npm publish`, ...). Everything else passes.
@@ -90,31 +90,31 @@ Two machine-checkable consequences:
90
90
  - **`scope-gate-audit`** (afterFileEdit, opt-in via `.scope.json` existing) audits every edit against `files[]` and quotes `intent` + `acceptance` back on a violation. Editing outside the declared set is the textbook scope-creep signal.
91
91
  - **final-review axis 0** (intent trace) traces every diff hunk back to `intent`. Anything untraceable is a hallucinated requirement.
92
92
 
93
- On the **first tool of each agent turn**, `intent-anchor` materializes the Anchor Set: it writes `.scope.json` to the repo root (regenerating it when the prompt changed) and re-injects it into context. One scaffold per prompt, re-injection per turn. The latch is armed on first fire and cleared **unconditionally** by the stop hook on every turn boundary, so the next turn re-fires and can never get stranded silenced mid-session.
93
+ **Before the agent's first token**, `intent-precompile` (`beforeSubmitPrompt`) materializes the Anchor Set: the moment you hit send it writes `.scope.json` to the repo root with `intent` locked from the prompt (which is in the event payload directly) and `acceptance` seeded with a real default — so the contract is the first artifact of the turn. `intent-anchor` (`postToolUse`) then re-injects it into context on the first tool boundary. One contract per prompt, re-injection per turn. The intent-anchor latch is armed on first fire and cleared **unconditionally** by the stop hook on every turn boundary, so the next turn re-fires and can never get stranded silenced mid-session.
94
94
 
95
95
  The Anchor Set is skipped for trivial one-liners (typo, literal) — the `declared-editing.md` ladder's rung 1 governs when it's overkill.
96
96
 
97
- ### Keeping the contract alive: `intent-anchor` (anti-Salience-Dilution)
97
+ ### Contract first, then kept alive: `intent-precompile` + `intent-anchor`
98
98
 
99
- Writing `.scope.json` once is not enough. As a conversation fills with code, logs and errors, the token of the original request shrinks to a rounding error against the recent history — *Salience Dilution* — and the agent stops checking the contract it wrote at prompt 1. It forgets symmetry, colors, the acceptance bar. Re-injecting the contract every turn is what defeats this: the requirements are re-stated in front of the model before each edit, not just written once and hoped-for.
99
+ The contract has to exist *before* the agent edits and stay in focus *as* it edits. Two hooks, two jobs:
100
100
 
101
- `intent-anchor` (`postToolUse`, registered first so it runs before `post-tool-use` drains the bus) does two things on the **first tool boundary of every turn** (per-turn latch, cleared unconditionally at each stop):
101
+ **`intent-precompile` (`beforeSubmitPrompt`) write it first.** This event fires right after the user hits send, before the backend request, with the user's `prompt` in the payload directly — no `<user_query>` extraction, no transcript dependency, no contamination from auto-submitted review followups. When the prompt is new (its `_intent_hash` differs from the contract on disk) it writes a fresh `.scope.json`: `intent` locked from the prompt, `files: []`, `acceptance` seeded with a real default (never a bare `<TODO>` — a placeholder that looks owned and never gets filled was the old failure mode), `_intent_hash` for change detection. It also stashes the verbatim prompt so `intent-anchor` reads the *same* ground-truth text. Same prompt on disk → left intact, preserving the agent's refined `intent`/`acceptance`/`files`. Hook-generated submits are skipped.
102
102
 
103
- 1. **Materialize the contract per prompt.** When the current `<user_query>` differs from the contract on disk (no contract yet, or its recorded `_intent_hash` doesn't match), the hook **writes a fresh `.scope.json` to the repo root**: `intent` locked from the prompt, `files`/`acceptance` as `<TODO>` placeholders the agent fills in. Every new prompt a fresh contract the agent works from. Same prompt no rewrite.
104
- 2. **Re-inject the contract every turn.** Reads `.scope.json` and stashes `intent` + `files` + `acceptance` into the feedback bus, which `post-tool-use` delivers as `additional_context`. The contract is back in the model's attentional focus at the start of each turn, before edits pile up and dilute it.
103
+ **`intent-anchor` (`postToolUse`, registered first so it runs before `post-tool-use` drains the bus) keep it alive.** As a conversation fills with code, logs and errors, the token of the original request shrinks to a rounding error against recent history — *Salience Dilution* — and the agent stops checking the contract. So on the first tool boundary of every turn (per-turn latch, cleared unconditionally at each stop) it reads `.scope.json` and stashes `intent` + `files` + `acceptance` into the feedback bus, which `post-tool-use` delivers as `additional_context` the contract back in attentional focus before edits pile up. It also **falls back to creating the contract** if `beforeSubmitPrompt` didn't run (older Cursor), and re-injects a loud demand each turn until the agent sharpens the seeded `acceptance` to the one deterministic check.
105
104
 
106
- > **The hook writes `.scope.json` deliberately.** This is the intended behavior: the contract must exist *before* the agent edits, and it must track the request. The agent fills the `<TODO>` placeholders (`files`, `acceptance`) on the first turn; the hook regenerates the scaffold when the prompt changes. Two real bugs in the earlier 0.4.4 build are fixed here: (a) **never writes to `$HOME`** — if the repo `cwd` can't be resolved the hook stays silent rather than drop a ghost file (bail instead of fallback); (b) **regenerates on prompt CHANGE, not on every turn** — staleness is tracked via `_intent_hash` stored in the file itself, so a same-prompt turn re-injects without rewriting.
105
+ > **The hooks write `.scope.json` deliberately.** The contract must exist before the agent edits and track the request. Safeguards: (a) **never writes to `$HOME`** — if the repo root can't be resolved the hook stays silent rather than drop a ghost file; (b) **regenerates on prompt CHANGE, not every turn** — staleness is tracked via `_intent_hash` in the file, and `intent-precompile`/`intent-anchor` share one hashing helper so a same-prompt turn re-injects without rewriting; (c) **`trace.query` stays verbatim** as the audit anchor even when the agent normalizes `intent`.
107
106
 
108
107
  Crucially, `intent-anchor` carries the **semantic** contract (`intent`/`acceptance`) into context every turn — something the path-only `scope-gate-audit` can never do. That is what makes "the agent forgot about grid symmetry while editing the right file" catchable: the symmetry requirement is re-stated in front of the model before each edit, not just checked against a file list after.
109
108
 
110
- ## The five flows
109
+ ## The flows
111
110
 
112
111
  | Flow | Event | What happens |
113
112
  |---|---|---|
113
+ | Submit | `beforeSubmitPrompt` | **`intent-precompile`** writes `.scope.json` to the repo root from the prompt in the payload — **before the agent's first token** — with `intent` locked and `acceptance` seeded, and stashes the verbatim prompt for `intent-anchor`. Skips hook-generated auto-submits; hash-gated so the agent's refinements survive. |
114
114
  | Session | `sessionStart` | `inject-doctrine` reads doctrine + user rules + declared-editing + **pre-compile** and emits them as `additional_context`. |
115
- | Every turn | `postToolUse` | **`intent-anchor`** (registered first) re-injects `.scope.json` into `additional_context` at the first tool boundary of each turn — the anti-Salience-Dilution move that keeps `intent` + `acceptance` in the model's attentional focus before edits pile up. If the prompt changed since last turn, it demands the contract be updated. Then `post-tool-use` folds subagent markers and drains the feedback file. |
115
+ | Every turn | `postToolUse` | **`intent-anchor`** (registered first) re-injects `.scope.json` into `additional_context` at the first tool boundary of each turn — the anti-Salience-Dilution move that keeps `intent` + `acceptance` in the model's attentional focus before edits pile up, and demands the seeded `acceptance` be sharpened. Then `post-tool-use` folds subagent markers and drains the feedback file. |
116
116
  | Shell | `beforeShellExecution` | `permission-gate` checks the command against a deny list. Allow by default, deny by list, fail open. |
117
- | Edit | `afterFileEdit` + `stop` | **Proactive:** `intent-anchor` (`postToolUse`) scaffolds `.scope.json` per prompt and re-injects it each turn. **Reactive:** `self-review-trigger` stashes the review prompt per edit; `semantic-density-audit`, `scope-gate-audit` (opt-in, audits `.scope.json`), and `anti-slop-audit` append advisories when they trip; `final-review` fires one end-of-implementation seven-axis pass. |
117
+ | Edit | `afterFileEdit` + `stop` | **Proactive:** `intent-precompile` writes the contract per prompt; `intent-anchor` re-injects it each turn. **Reactive:** `self-review-trigger` stashes the review prompt per edit; `semantic-density-audit`, `scope-gate-audit` (opt-in, audits `.scope.json`), and `anti-slop-audit` append advisories when they trip; `final-review` fires one end-of-implementation seven-axis pass. |
118
118
  | Subagent | `subagentStop` | `subagent-stop-review` fires one in-subagent final review when a delegated run edited files, before the result returns to the parent. Marker-gated and flag-braked like `final-review`. |
119
119
 
120
120
  ## Layout
@@ -6,13 +6,15 @@ the task itself is a behavior change. Refactors, renames, cleanup only when
6
6
  asked. Leave generated files alone unless explicitly required.
7
7
 
8
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.
9
+ The harness writes `.scope.json` to the repo root the moment you hit send -
10
+ BEFORE your first token - with `intent` locked from the request, and re-injects
11
+ it every turn. Treat it as your operating contract, not optional:
12
+ - It is the FIRST thing that exists each turn; govern by it from your first action.
13
+ As your first edits, refine `intent` to your normalized restatement and SHARPEN
14
+ the seeded `acceptance` to the one deterministic check (it is a draft, not a
15
+ blank `<TODO>`). `files[]` is auto-tracked - do not maintain it.
16
+ - When the user's request changes, the contract regenerates with the new intent -
17
+ refine it again for the new ask.
16
18
  - If a hook surfaces the contract, defer to it: it outranks momentum. Edit
17
19
  inside the declared scope; if you must grow it, justify it, don't sneak past.
18
20
 
@@ -79,6 +79,50 @@ safe_conversation_id() {
79
79
 
80
80
  hooks_pending_dir() { printf '%s' "$HOME/.cursor/.hooks-pending"; }
81
81
 
82
+ # sha256_hex <text> -> SHA-256 hex. SHARED so intent-precompile (beforeSubmitPrompt)
83
+ # and intent-anchor (postToolUse) hash the SAME text the SAME way; otherwise they
84
+ # disagree on _intent_hash and postToolUse needlessly rewrites the prompt hook's
85
+ # scope. Mirrors the hashing intent-anchor.sh already does (sha256sum|shasum).
86
+ sha256_hex() {
87
+ if command -v sha256sum >/dev/null 2>&1; then
88
+ printf '%s' "$1" | sha256sum | awk '{print $1}'
89
+ elif command -v shasum >/dev/null 2>&1; then
90
+ printf '%s' "$1" | shasum -a 256 | awk '{print $1}'
91
+ fi
92
+ }
93
+
94
+ # Path where beforeSubmitPrompt stashes the verbatim user prompt for the turn.
95
+ # intent-anchor PREFERS this over transcript parsing: ground-truth request from
96
+ # the payload, present on the FIRST postToolUse, immune to <user_query>
97
+ # contamination.
98
+ current_prompt_path() { printf '%s' "$(hooks_pending_dir)/current-prompt-$1.txt"; }
99
+
100
+ # stashed_prompt <cid> -> the stashed prompt ('' if none). Only ever holds real
101
+ # human prompts - intent-precompile filters hook-generated submits. (The caller's
102
+ # command substitution strips the trailing newline.)
103
+ stashed_prompt() {
104
+ local p; p="$(current_prompt_path "$1")"
105
+ [ -f "$p" ] && cat "$p" 2>/dev/null
106
+ }
107
+
108
+ # default_acceptance -> the real default the hook seeds so .scope.json NEVER ships
109
+ # a bare "<TODO>" (the thing that looks broken and never gets filled). A verifiable
110
+ # bar the agent then sharpens to the single deterministic check. ONE place so both
111
+ # hooks emit the identical string.
112
+ default_acceptance() {
113
+ printf '%s' 'Every change traces to intent; the project typecheck/build and any *.selfcheck pass, and the described problem no longer reproduces. (Sharpen to the one deterministic check.)'
114
+ }
115
+
116
+ # redact_secrets <text> -> portable (sed) secret scrub for text we persist to
117
+ # .scope.json / the prompt stash. Mirrors the python redaction in
118
+ # extract_last_user_query so the beforeSubmitPrompt path is no leakier.
119
+ redact_secrets() {
120
+ printf '%s' "$1" | sed -E \
121
+ -e 's/\bnpm_[A-Za-z0-9]{10,}\b/[REDACTED_NPM_TOKEN]/g' \
122
+ -e 's/\b(sk-[A-Za-z0-9]{10,}|ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,})\b/[REDACTED_TOKEN]/g' \
123
+ -e 's/([Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Tt][Oo][Kk][Ee][Nn]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd])[[:space:]]*[:=][[:space:]]*[^[:space:]]+/\1=[REDACTED]/g'
124
+ }
125
+
82
126
  # is_cursor_config_path <path> -> 0 if the path lives under a .cursor directory
83
127
  is_cursor_config_path() {
84
128
  case "$1" in
@@ -105,7 +149,7 @@ resolve_agent_path() {
105
149
  esac
106
150
  }
107
151
 
108
- # extract_last_user_query <json> -> text of the last <user_query> in this
152
+ # extract_last_user_query <json> -> text of the last *human* <user_query> in this
109
153
  # conversation's transcript, or '' if there is none. Capped at 2000 chars.
110
154
  #
111
155
  # This is the Tier 0 intent-trace primitive: the final-review hook prepends the
@@ -113,7 +157,14 @@ resolve_agent_path() {
113
157
  # to it. Anything untraceable is a hallucinated requirement.
114
158
  #
115
159
  # Walks the JSONL backward via tac (preferred) or a portable awk fallback; finds
116
- # the first (last) user record whose content carries a <user_query> tag.
160
+ # the first (last) user record whose content carries a <user_query> tag - SKIPPING
161
+ # hook-generated turns. final-review.sh / subagent-stop-review.sh emit a
162
+ # {followup_message} that Cursor replays as a user turn (and self-review /
163
+ # intent-anchor inject into additional_context); returning one of those would lock
164
+ # the review boilerplate into .scope.json as the intent (the contamination loop).
165
+ # Hook turns are detected by their fixed headers; if the transcript has been
166
+ # trimmed to just the hook turn, recover the real request from the embedded
167
+ # "ORIGINAL REQUEST ... --- <request> ---" block.
117
168
  extract_last_user_query() {
118
169
  local json="$1"
119
170
  local tp
@@ -134,6 +185,14 @@ extract_last_user_query() {
134
185
  if have_py; then
135
186
  printf '%s' "$reversed" | python3 -c '
136
187
  import json, re, sys
188
+ HOOK_HDR = re.compile(r"^\s*(FINAL REVIEW \(end of implementation\)|SUBAGENT FINAL REVIEW|SELF-REVIEW|INTENT ANCHOR)", re.M)
189
+ EMBEDDED = re.compile(r"ORIGINAL REQUEST[^\r\n]*\r?\n-{3,}\r?\n(.+?)\r?\n-{3,}", re.S)
190
+ def redact(q):
191
+ q = re.sub(r"\bnpm_[A-Za-z0-9]{10,}\b", "[REDACTED_NPM_TOKEN]", q)
192
+ q = re.sub(r"\b(sk-[A-Za-z0-9]{10,}|ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,})\b", "[REDACTED_TOKEN]", q)
193
+ q = re.sub(r"(?i)(api[_-]?key|token|secret|password)\s*[:=]\s*\S+", r"\1=[REDACTED]", q)
194
+ return q
195
+ embedded_fallback = ""
137
196
  try:
138
197
  for line in sys.stdin:
139
198
  line = line.strip()
@@ -155,15 +214,26 @@ try:
155
214
  if isinstance(p, dict) and p.get("type") == "text" and p.get("text"):
156
215
  text += p["text"]
157
216
  m = re.search(r"<user_query>\s*(.+?)\s*</user_query>", text, re.S)
158
- if m:
159
- q = m.group(1).strip()
160
- if len(q) > 2000:
161
- q = q[:2000] + "..."
162
- q = re.sub(r"\bnpm_[A-Za-z0-9]{10,}\b", "[REDACTED_NPM_TOKEN]", q)
163
- q = re.sub(r"\b(sk-[A-Za-z0-9]{10,}|ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,})\b", "[REDACTED_TOKEN]", q)
164
- q = re.sub(r"(?i)(api[_-]?key|token|secret|password)\s*[:=]\s*\S+", r"\1=[REDACTED]", q)
165
- print(q)
166
- break
217
+ if not m:
218
+ continue
219
+ q = m.group(1).strip()
220
+ # Hook-generated turn -> not the human words. Remember the embedded
221
+ # ORIGINAL REQUEST (latest such turn) and keep walking back.
222
+ if HOOK_HDR.search(q):
223
+ if not embedded_fallback:
224
+ em = EMBEDDED.search(q)
225
+ if em:
226
+ embedded_fallback = em.group(1).strip()
227
+ continue
228
+ if len(q) > 2000:
229
+ q = q[:2000] + "..."
230
+ print(redact(q))
231
+ break
232
+ else:
233
+ if embedded_fallback:
234
+ if len(embedded_fallback) > 2000:
235
+ embedded_fallback = embedded_fallback[:2000] + "..."
236
+ print(redact(embedded_fallback))
167
237
  except Exception:
168
238
  pass
169
239
  ' 2>/dev/null
@@ -172,9 +242,14 @@ except Exception:
172
242
 
173
243
  # No python3: best-effort grep for the common case where the user message
174
244
  # is the only place <user_query> appears in a line. Imperfect but bounded.
245
+ # Drop hook-generated turns (FINAL REVIEW / SUBAGENT / SELF-REVIEW / INTENT
246
+ # ANCHOR) so we never lock review boilerplate as the intent; take the first
247
+ # surviving human <user_query>.
175
248
  printf '%s' "$reversed" |
176
- grep -m1 -oE '<user_query>[^<]*</user_query>' 2>/dev/null |
249
+ grep -oE '<user_query>[^<]*</user_query>' 2>/dev/null |
177
250
  sed -E 's@</?user_query>@@g' |
251
+ grep -vE '^[[:space:]]*(FINAL REVIEW \(end of implementation\)|SUBAGENT FINAL REVIEW|SELF-REVIEW|INTENT ANCHOR)' 2>/dev/null |
252
+ head -n1 |
178
253
  sed -E 's/\bnpm_[A-Za-z0-9]{10,}\b/[REDACTED_NPM_TOKEN]/g' |
179
254
  head -c 2000
180
255
  }
@@ -74,19 +74,20 @@ fi
74
74
  # Already injected this turn -> quiet. Latch cleared at every stop.
75
75
  [ -f "$latch" ] && exit 0
76
76
 
77
- # --- current request (best-effort; absent in sandboxed runs) -----------------
78
- current_query="$(extract_last_user_query "$input")"
77
+ # --- current request ---------------------------------------------------------
78
+ # PREFER the prompt stashed by intent-precompile (beforeSubmitPrompt): ground-truth
79
+ # request from the payload, present on the FIRST postToolUse, immune to <user_query>
80
+ # contamination. Fall back to transcript parsing only when the prompt hook did not
81
+ # run. Both share sha256_hex so _intent_hash is consistent across the two hooks.
82
+ current_query="$(stashed_prompt "$cid")"
83
+ [ -n "$current_query" ] || current_query="$(extract_last_user_query "$input")"
79
84
  has_query=0
80
85
  [ -n "$current_query" ] && has_query=1
81
86
 
82
87
  current_hash=""
83
88
  prompt_changed=0
84
89
  if [ "$has_query" = "1" ]; then
85
- if command -v sha256sum >/dev/null 2>&1; then
86
- current_hash="$(printf '%s' "$current_query" | sha256sum | awk '{print $1}')"
87
- elif command -v shasum >/dev/null 2>&1; then
88
- current_hash="$(printf '%s' "$current_query" | shasum -a 256 | awk '{print $1}')"
89
- fi
90
+ current_hash="$(sha256_hex "$current_query")"
90
91
  prev_hash=""
91
92
  [ -f "$hash_file" ] && prev_hash="$(cat "$hash_file" 2>/dev/null)"
92
93
  [ "$current_hash" != "$prev_hash" ] && prompt_changed=1
@@ -150,12 +151,14 @@ EOF
150
151
  # changed (or a new session) => regenerate and RESET files[] (the "arrastre entre
151
152
  # features" fix). Same prompt this session => heal in place (backfill bookkeeping, keep
152
153
  # 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.
154
+ # Hollow = no real intent on disk: empty, the hook's <TODO> placeholder, OR
155
+ # hook-generated review boilerplate that a stale extractor locked in (the
156
+ # contamination loop - "FINAL REVIEW (end of implementation)..."). A hollow
157
+ # contract is worse than none (it looks owned, so neither hook nor agent fills
158
+ # it). Treat it as unusable: regenerate when the request is readable, else hand
159
+ # the agent the pre-compile demand to author a real one.
157
160
  case "$scope_intent" in
158
- ""|"<TODO"*) scope_hollow=1 ;;
161
+ ""|"<TODO"*|"FINAL REVIEW (end of implementation)"*|"SUBAGENT FINAL REVIEW"*|"SELF-REVIEW"*|"INTENT ANCHOR"*) scope_hollow=1 ;;
159
162
  esac
160
163
  if [ "$scope_exists" = "1" ] && [ "$has_query" = "1" ]; then
161
164
  if [ "$scope_hollow" = "1" ]; then
@@ -194,19 +197,21 @@ if [ "$should_create" = "1" ] || [ "$should_regen" = "1" ]; then
194
197
  # Both paths require $has_query, so intent is always locked from the request.
195
198
  intent_val="$current_query"
196
199
  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.
200
+ default_acc="$(default_acceptance)"
201
+ # jq preferred; python3 fallback. Write intent, empty files[], a real seeded
202
+ # acceptance (never a bare <TODO>), trace provenance, and _intent_hash so
203
+ # staleness is self-contained.
199
204
  if have_jq; then
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"}' \
205
+ jq -n --arg intent "$intent_val" --arg hash "$current_hash" --arg tq "$trace_query" --arg ts "$now_ts" --arg acc "$default_acc" \
206
+ '{intent:$intent, files:[], acceptance:$acc, allow_growth:false, trace:{query:$tq, ts:$ts}, _intent_hash:$hash, _generated_by:"intent-anchor hook"}' \
202
207
  > "$scope_path" 2>/dev/null && regenerated=1
203
208
  elif have_py; then
204
- if I_FILE="$scope_path" I_INTENT="$intent_val" I_HASH="$current_hash" I_TQ="$trace_query" I_TS="$now_ts" python3 -c '
209
+ if I_FILE="$scope_path" I_INTENT="$intent_val" I_HASH="$current_hash" I_TQ="$trace_query" I_TS="$now_ts" I_ACC="$default_acc" python3 -c '
205
210
  import json, os
206
211
  obj = {
207
212
  "intent": os.environ["I_INTENT"],
208
213
  "files": [],
209
- "acceptance": "<TODO: the one deterministic check that decides done>",
214
+ "acceptance": os.environ["I_ACC"],
210
215
  "allow_growth": False,
211
216
  "trace": {"query": os.environ["I_TQ"], "ts": os.environ["I_TS"]},
212
217
  "_intent_hash": os.environ["I_HASH"],
@@ -220,7 +225,7 @@ with open(os.environ["I_FILE"], "w", encoding="utf-8") as f:
220
225
  fi
221
226
  if [ "$regenerated" = "1" ]; then
222
227
  scope_intent="$intent_val"
223
- scope_acceptance="<TODO: the one deterministic check that decides done>"
228
+ scope_acceptance="$default_acc"
224
229
  scope_files="(auto-tracked - the scope hook records every file you edit)"
225
230
  scope_exists=1
226
231
  scope_stale=0
@@ -269,6 +274,17 @@ else
269
274
  query_line="(current request unavailable - no transcript in this event)"
270
275
  fi
271
276
 
277
+ # acceptance is seeded with a real default (never a bare <TODO>), but the default
278
+ # is generic - the contract is not fully set until the agent sharpens it to the
279
+ # ONE deterministic check. Detect the unsharpened state -> loud demand each turn.
280
+ acceptance_demand=""
281
+ case "$scope_acceptance" in
282
+ "<TODO"*|*"Sharpen to the one deterministic check"*)
283
+ acceptance_demand="
284
+
285
+ >> acceptance is still the seeded default. Your FIRST action this turn is a targeted string-replace on .scope.json setting acceptance to the one deterministic check that decides done - then do the work." ;;
286
+ esac
287
+
272
288
  if [ "$regenerated" = "1" ]; then
273
289
  msg="INTENT ANCHOR (scope regenerated) - .scope.json written for this prompt.
274
290
 
@@ -278,9 +294,9 @@ if [ "$regenerated" = "1" ]; then
278
294
 
279
295
  The hook wrote a fresh scaffold to $scope_path from your current request. intent
280
296
  is locked from what you just asked. files[] is AUTO-TRACKED - the scope hook
281
- records every file you edit, so do not maintain it by hand. Set acceptance to
282
- the one deterministic check that decides done, THEN proceed. This contract will
283
- be re-injected every turn until your request changes again."
297
+ records every file you edit, so do not maintain it by hand. acceptance is seeded
298
+ with a sensible default; sharpen it to the one deterministic check, THEN proceed.
299
+ This contract will be re-injected every turn until your request changes again.$acceptance_demand"
284
300
  elif [ "$scope_exists" != "1" ] || [ "$scope_hollow" = "1" ]; then
285
301
  if [ "$scope_hollow" = "1" ]; then
286
302
  state="the .scope.json in $root is only a <TODO> placeholder (the hook could not read your request to fill it)"
@@ -314,7 +330,7 @@ else
314
330
  acceptance: $scope_acceptance
315
331
 
316
332
  $drift_note If a constraint above conflicts with what you are about to do, stop
317
- and reconcile - the contract outranks momentum."
333
+ and reconcile - the contract outranks momentum.$acceptance_demand"
318
334
  fi
319
335
 
320
336
  # --- stash to the feedback bus (drained by post-tool-use.sh) -----------------
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env bash
2
+ # intent-precompile.sh - beforeSubmitPrompt "contract first" writer (Cursor, Linux).
3
+ #
4
+ # THE FIX for "el .scope.json se crea casi al final": creation used to live on
5
+ # postToolUse (intent-anchor), which fires only AFTER the agent's first tool and
6
+ # depends on the transcript becoming readable to detect the prompt. Until then the
7
+ # PREVIOUS prompt's intent persisted and the agent worked under it - the scope only
8
+ # flipped to the right intent late in the turn.
9
+ #
10
+ # beforeSubmitPrompt fires "right after the user hits send, before the backend
11
+ # request" - BEFORE the agent's first token - and its payload carries the user's
12
+ # `prompt` DIRECTLY (no <user_query> extraction, no transcript dependency, no
13
+ # hook-followup contamination). So this hook writes .scope.json with the real
14
+ # intent up front, making the contract the FIRST artifact of the turn.
15
+ #
16
+ # Two deterministic jobs:
17
+ # 1. STASH the verbatim prompt to current-prompt-<cid>.txt. intent-anchor prefers
18
+ # it over transcript parsing, so both hooks hash the SAME text (sha256_hex)
19
+ # and never fight over _intent_hash.
20
+ # 2. WRITE / REGENERATE .scope.json from the prompt when it is new (hash differs)
21
+ # or missing. Same prompt on disk -> leave it (preserve the agent's refined
22
+ # intent / acceptance / files). acceptance is seeded with a real default
23
+ # (default_acceptance), never a bare <TODO>.
24
+ #
25
+ # Hook-generated submits (auto-submitted final-review / subagent-review followups)
26
+ # are SKIPPED: their prompt is review boilerplate, not the user's request.
27
+ #
28
+ # Never blocks submission; writes files as a side effect and exits 0. Disable:
29
+ # HOOKS_ENFORCE=0 or INTENT_ANCHOR_ENFORCE=0 (shares the intent-anchor kill switch).
30
+
31
+ set +e
32
+ . "$(dirname "$0")/hook-common.sh"
33
+
34
+ [ "${HOOKS_ENFORCE:-}" = "0" ] && exit 0
35
+ [ "${INTENT_ANCHOR_ENFORCE:-}" = "0" ] && exit 0
36
+
37
+ input="$(read_hook_stdin)"
38
+ [ -n "$input" ] || exit 0
39
+
40
+ # --- the prompt (direct from payload - the whole point of this event) --------
41
+ prompt="$(json_get "$input" prompt)"
42
+ # trim leading/trailing whitespace
43
+ prompt="$(printf '%s' "$prompt" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
44
+ [ -n "$prompt" ] || exit 0
45
+
46
+ # Auto-submitted hook followups are not the user's request -> leave the contract.
47
+ case "$prompt" in
48
+ "FINAL REVIEW (end of implementation)"*|"SUBAGENT FINAL REVIEW"*|"SELF-REVIEW"*|"INTENT ANCHOR"*) exit 0 ;;
49
+ esac
50
+
51
+ prompt="$(redact_secrets "$prompt")"
52
+
53
+ cid="$(safe_conversation_id "$input")"
54
+ pending_dir="$(hooks_pending_dir)"
55
+
56
+ # --- repo root (workspace_roots / cwd; NO $HOME fallback - no ghost files) ----
57
+ root=""
58
+ while IFS= read -r cand; do
59
+ [ -n "$cand" ] && [ -d "$cand" ] && { root="${cand%/}"; break; }
60
+ done <<EOF
61
+ $(json_get "$input" cwd)
62
+ $(json_get_array "$input" workspace_roots)
63
+ EOF
64
+ if [ -z "$root" ] && [ -n "$CURSOR_PROJECT_DIR" ] && [ -d "$CURSOR_PROJECT_DIR" ]; then
65
+ root="${CURSOR_PROJECT_DIR%/}"
66
+ fi
67
+ [ -n "$root" ] || exit 0
68
+
69
+ # --- stash the prompt so intent-anchor reads the same ground-truth text -------
70
+ mkdir -p "$pending_dir" 2>/dev/null
71
+ printf '%s' "$prompt" > "$(current_prompt_path "$cid")" 2>/dev/null
72
+
73
+ # --- write / regenerate .scope.json (hash-gated) ------------------------------
74
+ current_hash="$(sha256_hex "$prompt")"
75
+ scope_path="$root/.scope.json"
76
+ on_disk_hash=""
77
+ if [ -f "$scope_path" ]; then
78
+ if have_jq; then
79
+ on_disk_hash="$(jq -r '._intent_hash // empty' "$scope_path" 2>/dev/null)"
80
+ elif have_py; then
81
+ on_disk_hash="$(python3 -c 'import json,sys
82
+ try: print(json.load(open(sys.argv[1])).get("_intent_hash","") or "")
83
+ except Exception: pass' "$scope_path" 2>/dev/null)"
84
+ fi
85
+ fi
86
+
87
+ # Same prompt already locked (prior fire this turn, or the agent refined the
88
+ # contract) -> leave it. Only (re)write on a NEW or missing/garbage contract.
89
+ [ "$on_disk_hash" = "$current_hash" ] && [ -n "$current_hash" ] && exit 0
90
+
91
+ now_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
92
+ default_acc="$(default_acceptance)"
93
+ if have_jq; then
94
+ jq -n --arg intent "$prompt" --arg hash "$current_hash" --arg ts "$now_ts" --arg acc "$default_acc" \
95
+ '{intent:$intent, files:[], acceptance:$acc, allow_growth:false, trace:{query:$intent, ts:$ts}, _intent_hash:$hash, _generated_by:"intent-precompile hook (beforeSubmitPrompt)"}' \
96
+ > "$scope_path" 2>/dev/null
97
+ elif have_py; then
98
+ I_FILE="$scope_path" I_INTENT="$prompt" I_HASH="$current_hash" I_TS="$now_ts" I_ACC="$default_acc" python3 -c '
99
+ import json, os
100
+ obj = {
101
+ "intent": os.environ["I_INTENT"],
102
+ "files": [],
103
+ "acceptance": os.environ["I_ACC"],
104
+ "allow_growth": False,
105
+ "trace": {"query": os.environ["I_INTENT"], "ts": os.environ["I_TS"]},
106
+ "_intent_hash": os.environ["I_HASH"],
107
+ "_generated_by": "intent-precompile hook (beforeSubmitPrompt)",
108
+ }
109
+ with open(os.environ["I_FILE"], "w", encoding="utf-8") as f:
110
+ json.dump(obj, f, ensure_ascii=False, indent=2)
111
+ ' 2>/dev/null
112
+ fi
113
+
114
+ exit 0
package/linux/hooks.json CHANGED
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "version": 1,
3
3
  "hooks": {
4
+ "beforeSubmitPrompt": [
5
+ {
6
+ "command": "bash ~/.agents/hooks/intent-precompile.sh",
7
+ "timeout": 5,
8
+ "_comment": "5s: CONTRACT FIRST. Fires right after the user hits send, BEFORE the agent's first token, with the user's `prompt` in the payload directly. Writes/regenerates .scope.json (intent locked from the prompt, acceptance seeded with a real default - never a bare <TODO>) so the contract is the FIRST artifact of the turn instead of appearing late once postToolUse can read the transcript. Also stashes the verbatim prompt to current-prompt-<cid>.txt so intent-anchor (postToolUse) re-injects from the SAME ground-truth text - no <user_query> contamination, no _intent_hash fights. Skips hook-generated auto-submits (FINAL REVIEW / SUBAGENT / SELF-REVIEW). Hash-gated: same prompt on disk -> left intact (preserves the agent's refined intent/acceptance/files). Never blocks submission, always exits 0. Disable: HOOKS_ENFORCE=0 or INTENT_ANCHOR_ENFORCE=0."
9
+ }
10
+ ],
4
11
  "sessionStart": [
5
12
  {
6
13
  "command": "bash ~/.cursor/inject-doctrine.sh",
@@ -53,23 +53,30 @@ Answer these four, terse, in your first response. One phrase each, not prose:
53
53
 
54
54
  ## Materialize it: .scope.json (the hook owns this file)
55
55
 
56
- The `intent-anchor` hook creates and maintains `.scope.json` in the repo root for
57
- you, automatically, on the first tool of every turn:
58
- - `intent` is seeded from your verbatim request and REFRESHED when the request
59
- changesa new prompt regenerates the contract and resets `files[]`, so it
60
- never carries over between features;
56
+ The contract is written for you **before your first token**: the `intent-precompile`
57
+ hook fires on `beforeSubmitPrompt` (right after the user hits send) and writes
58
+ `.scope.json` to the repo root with the real `intent` already locked from the
59
+ requestso the contract is the FIRST artifact of the turn, and you govern by it
60
+ from the very first action. `intent-anchor` then re-injects it on every tool
61
+ boundary to keep it in focus.
62
+ - `intent` is locked from the request and REFRESHED when the request changes — a
63
+ new prompt regenerates the contract and resets `files[]`, so it never carries
64
+ over between features;
61
65
  - `files[]` is auto-recorded — the scope hook appends every file you edit, so
62
66
  you never maintain it by hand;
67
+ - `acceptance` is SEEDED with a real default (never a bare `<TODO>`); it is not a
68
+ blank you must fill, it is a draft you SHARPEN;
63
69
  - `trace.query` is the VERBATIM request (the audit anchor), `_intent_hash` and
64
70
  `_generated_by` are hook bookkeeping. Leave all three alone.
65
71
 
66
72
  Your two targeted edits on the contract (each a string replace on ONE field, never
67
- a whole-file rewrite):
68
- - **`intent`** → replace the verbatim seed with your Step 0 restatement: the
69
- normalized, meaning-preserving sentence. This is what final-review axis 0
70
- traces each diff hunk against, so a clean `intent` makes the audit sharp.
71
- - **`acceptance`** → set the single deterministic check that decides done, which
72
- the hook cannot derive.
73
+ a whole-file rewrite), done as your FIRST actions this turn before editing source:
74
+ - **`intent`** → replace the seed with your Step 0 restatement: the normalized,
75
+ meaning-preserving sentence. This is what final-review axis 0 traces each diff
76
+ hunk against, so a clean `intent` makes the audit sharp.
77
+ - **`acceptance`** → sharpen the seeded default to the single deterministic check
78
+ that decides done, which the hook cannot derive. The hook re-injects a loud
79
+ demand every turn until you do.
73
80
 
74
81
  Do **NOT** touch `trace.query`, `_intent_hash`, or `_generated_by`, and do **NOT**
75
82
  rewrite the whole file: `_intent_hash` is computed from the verbatim `trace.query`,
@@ -82,11 +89,11 @@ changed the meaning: `intent` and `trace.query` must agree.
82
89
  {
83
90
  "intent": "<YOU refine this: your normalized Step 0 restatement>",
84
91
  "files": ["<auto-recorded by the hook as you edit>"],
85
- "acceptance": "<YOU set this: the deterministic check that decides done>",
92
+ "acceptance": "<seeded with a default; YOU sharpen to the deterministic check>",
86
93
  "allow_growth": false,
87
94
  "trace": { "query": "<VERBATIM request - the hook owns this, leave it>", "ts": "<when>" },
88
95
  "_intent_hash": "<hook bookkeeping>",
89
- "_generated_by":"intent-anchor hook"
96
+ "_generated_by":"intent-precompile / intent-anchor hook"
90
97
  }
91
98
  ```
92
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursordoctrine",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
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"
@@ -6,13 +6,15 @@ the task itself is a behavior change. Refactors, renames, cleanup only when
6
6
  asked. Leave generated files alone unless explicitly required.
7
7
 
8
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.
9
+ The harness writes `.scope.json` to the repo root the moment you hit send -
10
+ BEFORE your first token - with `intent` locked from the request, and re-injects
11
+ it every turn. Treat it as your operating contract, not optional:
12
+ - It is the FIRST thing that exists each turn; govern by it from your first action.
13
+ As your first edits, refine `intent` to your normalized restatement and SHARPEN
14
+ the seeded `acceptance` to the one deterministic check (it is a draft, not a
15
+ blank `<TODO>`). `files[]` is auto-tracked - do not maintain it.
16
+ - When the user's request changes, the contract regenerates with the new intent -
17
+ refine it again for the new ask.
16
18
  - If a hook surfaces the contract, defer to it: it outranks momentum. Edit
17
19
  inside the declared scope; if you must grow it, justify it, don't sneak past.
18
20
 
@@ -172,6 +172,86 @@ if ($logCount -ge 6) {
172
172
  $opsFlags.Add("- TELEMETRY SPAM: $logCount log/print statements added in this one edit. Debug-level telemetry that nobody reads is slop; consolidate or remove (kept only if this is a real logging entrypoint).")
173
173
  }
174
174
 
175
+ # --- signal 5: AI vibe-coding structural slop (Arrow / Masking / Boolean /
176
+ # Switch Bloat) - high-precision regex/heuristics, deterministic, language-agnostic
177
+ # where possible. Each rule flags a SPECIFIC pattern that the model does because
178
+ # it cannot combine conditions or design types; the rule's "Stop" forces the
179
+ # canonical fix (guard clauses, fail-fast, named types, dictionary dispatch).
180
+ $vibeFlags = New-Object System.Collections.Generic.List[string]
181
+
182
+ # (5a) ARROW CODE: 3+ levels of nested if/for/while/switch in the ADDED lines.
183
+ # Measure indent depth of control-flow keywords; 3+ distinct nesting levels in
184
+ # one block = arrow code. The model nests because it cannot combine conditions.
185
+ # Stop: guard clauses (early returns) - read top-to-bottom, no deep indent.
186
+ $controlKw = '(?i)^\s*(if|for|while|switch|try|else|elif|else\s+if)\b'
187
+ $nestHits = 0
188
+ $maxNest = 0
189
+ foreach ($a in $added) {
190
+ if ($a -notmatch $controlKw) { continue }
191
+ $spaces = ($a -replace '\S.*$', '').Length
192
+ # Normalize: assume 2-space or tab indent. Floor to indent LEVELS.
193
+ $indents = [int][math]::Floor($spaces / 2)
194
+ if ($indents -ge 3) { # 3+ levels of nesting
195
+ $nestHits++
196
+ if ($indents -gt $maxNest) { $maxNest = $indents }
197
+ }
198
+ }
199
+ if ($nestHits -ge 2) {
200
+ $vibeFlags.Add("- ARROW CODE: $nestHits control-flow lines at >= 3 levels of nesting (max level: $maxNest). The model nests because it cannot combine conditions. Stop: GUARD CLAUSES (early returns). If a condition does not hold, return immediately; the body must read top-to-bottom without deep indent.")
201
+ }
202
+
203
+ # (5b) SYMPTOM MASKING: empty catch, catch-and-swallow, or value-nullish-coalesced
204
+ # instead of fixing the source of null. The model patches the symptom (catch it,
205
+ # default it) instead of fixing why the value is invalid.
206
+ # - `catch {}` / `catch (e) {}` with no body OR a bare return/log
207
+ # - `x ?? defaultValue` / `x || default` on what should be a fail-fast path
208
+ # - `try { ... } catch { return ... }` swallowing an error
209
+ # Stop: FAIL-FAST. If a function receives invalid state, throw an explicit Error;
210
+ # never catch an error only to hide it.
211
+ $swallowRe = '(?i)catch\s*(\([^)]*\))?\s*\{\s*(?://[^\n]*)?(?:\}|return\s|//\s*ignore|/\*.*\*/\s*\})'
212
+ # Note: no leading \b - the ?? operator is not a word boundary.
213
+ $coalesceRe = '\?\?\s*(?:null|undefined|0|''''|""|\[\]|\{\}|false|new\b)'
214
+ $maskingHits = 0
215
+ foreach ($a in $added) {
216
+ if ($a -match $swallowRe) { $maskingHits++; continue }
217
+ if ($a -match $coalesceRe) { $maskingHits++ }
218
+ }
219
+ if ($maskingHits -ge 1) {
220
+ $vibeFlags.Add("- SYMPTOM MASKING: $maskingHits instance(s) of empty/swallowed catch or nullish-coalesced fallback detected. The model patches the symptom (`x ?? default`, `catch {} return`) instead of fixing WHY the value is invalid. Stop: FAIL-FAST. If a function receives invalid state, throw an explicit Error; never catch only to hide, never default over a value that should never be null.")
221
+ }
222
+
223
+ # (5c) BOOLEAN TRAP: a function that takes a positional boolean to switch behavior.
224
+ # `processData(data, true)` is a classic - the model uses one function + a flag
225
+ # instead of two functions or a strategy. Stop: no boolean flags; split the
226
+ # function or use a strategy/dictionary dispatch.
227
+ $boolParamRe = '(?i)\bfunction\s+\w+\s*\([^)]*\b(is[A-Z]|should[A-Z]|with[A-Z]|use[A-Z]|enable[A-Z]|disable[A-Z]|force[A-Z]|skip[A-Z]|verbose|dryRun|async|strict|deep|quick)\b\s*[:=]\s*(?:boolean|bool|true|false)[^)]*\)'
228
+ $arrowBoolRe = '(?i)(?:const|let|var|func|def|fn)\s+\w+\s*\([^)]*\b(is[A-Z]|should[A-Z]|with[A-Z]|use[A-Z]|enable[A-Z]|disable[A-Z]|force[A-Z]|skip[A-Z]|verbose|dryRun|strict|deep|quick)\??\s*[:=]\s*(?:boolean|bool|true|false)\b[^)]*\)'
229
+ $boolHits = 0
230
+ foreach ($a in $added) {
231
+ if ($a -match $boolParamRe) { $boolHits++; continue }
232
+ if ($a -match $arrowBoolRe) { $boolHits++ }
233
+ }
234
+ if ($boolHits -ge 1) {
235
+ $vibeFlags.Add("- BOOLEAN TRAP: $boolHits function(s) take a positional boolean to switch behavior (is*/should*/with*/use*/enable*/force*/skip*/verbose/dryRun/strict/deep/quick). The model uses a flag instead of two functions. Stop: SPLIT the function or use a strategy/dictionary dispatch. If the behavior changes, it is a different function.")
236
+ }
237
+
238
+ # (5d) SWITCH BLOAT: a switch or if/else-if chain with > 5 cases. The model maps
239
+ # states via switch instead of a dictionary. Stop: replace with a Record<Map, Fn>
240
+ # / dictionary dispatch (Map<Estado, Funcion>), never a switch with > 5 arms.
241
+ $switchRe = '(?i)\bswitch\b'
242
+ $caseRe = '(?i)^\s*(?:case\s|default\s*:|when\s)'
243
+ $elseifRe = '(?i)^\s*(?:}\s*)?else\s+if\b'
244
+ $caseCount = 0
245
+ $inSwitch = $false
246
+ foreach ($a in $added) {
247
+ if ($a -match $switchRe) { $inSwitch = $true; continue }
248
+ if ($inSwitch -and $a -match $caseRe) { $caseCount++; continue }
249
+ if ($a -match $elseifRe) { $caseCount++ }
250
+ }
251
+ if ($caseCount -ge 6) {
252
+ $vibeFlags.Add("- SWITCH BLOAT: $caseCount case/branch arms detected in one switch or if/else-if chain. The model maps states via switch. Stop: DICTIONARY DISPATCH - a Record<Estado, Funcion> / Map<state, handler> lookup, never a switch with > 5 arms.")
253
+ }
254
+
175
255
  # --- decide whether to fire ----------------------------------------------
176
256
  $srcRe = '\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|kts|cs|cpp|cc|cxx|c|h|hpp|rb|php|swift|scala|m|mm|sh|ps1|lua|dart|ex|exs|vue|svelte)$'
177
257
  $addedCode = 0
@@ -184,6 +264,7 @@ if ($depAdded) { $flags.Add("- DEPENDENCY: " + $base + " gained a d
184
264
  if ($patterns.Count -gt 0) { $flags.Add("- PREMATURE ABSTRACTION: " + ($patterns -join ', ') + " - is there a real, present problem (2-3+ call sites that exist today) that needs it? If it is speculative, delete it and write the direct code.") }
185
265
  if ($redundant.Count -gt 0) { $flags.Add("- REDUNDANT COMMENTS: " + ($redundant -join ' | ') + " - delete comments that restate the code; keep only WHY.") }
186
266
  $flags.AddRange($opsFlags)
267
+ $flags.AddRange($vibeFlags)
187
268
 
188
269
  if ($flags.Count -eq 0 -and -not $substantial) { exit 0 }
189
270
 
@@ -193,13 +274,19 @@ $checklist = ''
193
274
  if (Test-Path -LiteralPath $checklistFile) { $checklist = Get-Content -Raw -LiteralPath $checklistFile }
194
275
  if (-not $checklist) {
195
276
  $checklist = @'
196
- ANTI-SLOP — read ~/.agents/hooks/anti-slop.md (13 items). Fallback if missing:
277
+ ANTI-SLOP — read ~/.agents/hooks/anti-slop.md (17 items). Fallback if missing:
197
278
  1–10: edge cases, duplication, conventions, deps, premature abstraction,
198
279
  accidental complexity, tests (no tautologies), cargo cult, architecture,
199
280
  redundant comments / prompt residue.
200
281
  11: semantic contracts (behavior change without name/signature change).
201
282
  12: operational slop (retry w/o backoff, await-in-loop, telemetry spam).
202
283
  13: change surface (too many files for a simple request).
284
+ 14: SLAP - one function one abstraction level.
285
+ 15: phantom state - explicit lifecycle, no implicit call-order.
286
+ 16: primitive obsession - value objects over loose primitives with rules.
287
+ 17: loop-driven - prefer map/filter/reduce over imperative for on arrays.
288
+ Static vibe-coding signals also flagged inline when detected: arrow code,
289
+ symptom masking, boolean trap, switch bloat.
203
290
  Fix guilty items now. Never revert what the user asked for.
204
291
  '@
205
292
  }
@@ -50,5 +50,29 @@ Do not explain. If clean, say nothing.
50
50
  13. CHANGE SURFACE (Tier 5) — Did a simple request touch many files? Every
51
51
  file in the diff must trace to the task. Trim unrelated hunks.
52
52
 
53
+ 14. MIXED LEVELS OF ABSTRACTION (SLAP violation) — Does one function mix a DB
54
+ call, a string validation, and a date format? The model writes one blob
55
+ because it cannot see the layers. Stop: SLAP - one function, one level of
56
+ abstraction. Extract details to named helpers; the top function reads as a
57
+ recipe, the bottom functions do the work.
58
+
59
+ 15. PHANTOM STATE (temporal coupling) — Must callers invoke init() before
60
+ process()? Does the function break unless something else ran first? The
61
+ model never validates lifecycle. Stop: make state explicit - a state
62
+ machine, or a guard at the top of every public method that throws if the
63
+ object is not in the required state. No implicit call-order contracts.
64
+
65
+ 16. PRIMITIVE OBSESSION — Are you passing loose strings/numbers for things
66
+ that have rules (userId: string, email: string, amount: number)? The model
67
+ spreads primitives instead of value objects. Stop: if a primitive has
68
+ domain rules (validation, formatting, equality semantics), it is a named
69
+ type / value object, not a raw string. Stop at the boundary where it enters.
70
+
71
+ 17. LOOP-DRIVEN LOGIC (imperative where declarative fits) — Are you writing
72
+ for loops to transform arrays when the language has .map / .filter /
73
+ .reduce / list comprehensions? The model defaults to imperative mutation.
74
+ Stop: prefer pure higher-order functions for data transformation; reserve
75
+ for-loops for genuine early-exit, index-based, or perf-critical paths.
76
+
53
77
  Hard constraints: never revert what the USER asked for — slop is what got added
54
78
  on top. At most a few targeted edits, then stop.
@@ -39,6 +39,45 @@ function Get-HooksPendingDir {
39
39
  return Join-Path $HOME '.cursor\.hooks-pending'
40
40
  }
41
41
 
42
+ # SHA-256 hex of a string. SHARED so intent-precompile (beforeSubmitPrompt) and
43
+ # intent-anchor (postToolUse) hash the SAME text the SAME way - otherwise the two
44
+ # disagree on the contract's _intent_hash and the postToolUse hook needlessly
45
+ # regenerates the scope the prompt hook just wrote.
46
+ function Get-Sha256Hex([string]$text) {
47
+ if ($null -eq $text) { $text = '' }
48
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
49
+ $hasher = [System.Security.Cryptography.SHA256]::Create()
50
+ return (-join ($hasher.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }))
51
+ }
52
+
53
+ # Path where the beforeSubmitPrompt hook stashes the verbatim user prompt for the
54
+ # turn, keyed by conversation. intent-anchor PREFERS this over transcript parsing:
55
+ # it is the ground-truth request captured directly from the payload (no <user_query>
56
+ # extraction, no hook-followup contamination, available on the FIRST postToolUse
57
+ # of the turn instead of whenever the transcript happens to become readable).
58
+ function Get-CurrentPromptPath([string]$cid) {
59
+ return Join-Path (Get-HooksPendingDir) "current-prompt-$cid.txt"
60
+ }
61
+
62
+ # Read the stashed prompt for this conversation ('' if none). The stash only ever
63
+ # holds real human prompts - intent-precompile filters hook-generated submits.
64
+ function Get-StashedPrompt([string]$cid) {
65
+ $p = Get-CurrentPromptPath $cid
66
+ if (Test-Path -LiteralPath $p) {
67
+ $t = Get-Content -LiteralPath $p -Raw -ErrorAction SilentlyContinue
68
+ if ($t) { return $t.TrimEnd("`r", "`n") }
69
+ }
70
+ return ''
71
+ }
72
+
73
+ # Default acceptance the hook seeds so .scope.json NEVER ships a bare "<TODO>"
74
+ # placeholder (the thing that looks broken and never gets filled). It is a real,
75
+ # verifiable bar derived from intent; the agent sharpens it to the single
76
+ # deterministic check. Kept in ONE place so both hooks emit the identical string.
77
+ function Get-DefaultAcceptance {
78
+ return 'Every change traces to intent; the project typecheck/build and any *.selfcheck pass, and the described problem no longer reproduces. (Sharpen to the one deterministic check.)'
79
+ }
80
+
42
81
  function Test-IsCursorConfigPath([string]$path) {
43
82
  if (-not $path) { return $false }
44
83
  return ($path -match '(^|[\\/])\.cursor([\\/]|$)')
@@ -83,11 +122,36 @@ function Redact-SecretsFromIntent([string]$text) {
83
122
  return $text
84
123
  }
85
124
 
86
- # Extract the last user <user_query> from a Cursor transcript JSONL.
125
+ # A <user_query> turn can actually be a HOOK-GENERATED message replayed by Cursor
126
+ # as a user turn: final-review.ps1 and subagent-stop-review.ps1 emit a
127
+ # {followup_message} that Cursor auto-submits as the next user turn, and the
128
+ # self-review / intent-anchor injections get drained into additional_context.
129
+ # If Get-LastUserQuery returns one of those, intent-anchor locks the REVIEW
130
+ # BOILERPLATE into .scope.json as the "intent" (the contamination loop that put
131
+ # "FINAL REVIEW (end of implementation)..." in the contract). Detect them by the
132
+ # fixed headers the hooks emit and skip past them to the real human turn.
133
+ function Test-IsHookGeneratedQuery([string]$text) {
134
+ if (-not $text) { return $false }
135
+ return ($text -match '(?m)^\s*(FINAL REVIEW \(end of implementation\)|SUBAGENT FINAL REVIEW|SELF-REVIEW|INTENT ANCHOR)')
136
+ }
137
+
138
+ # The final-review / subagent-review followups embed the real human request as:
139
+ # ORIGINAL REQUEST (...):\n---\n<request>\n---
140
+ # When the transcript has been trimmed to just the hook turn, recover the human
141
+ # request from that block instead of returning the boilerplate.
142
+ function Get-EmbeddedOriginalRequest([string]$text) {
143
+ if (-not $text) { return '' }
144
+ if ($text -match '(?s)ORIGINAL REQUEST[^\r\n]*\r?\n-{3,}\r?\n(.+?)\r?\n-{3,}') {
145
+ return $Matches[1].Trim()
146
+ }
147
+ return ''
148
+ }
149
+
150
+ # Extract the last *human* user <user_query> from a Cursor transcript JSONL.
87
151
  # transcript is an array of {role, message} records; we walk backward from the
88
- # end, find the last user turn whose content has a <user_query> tag, and return
89
- # its text. Returns '' if there is no transcript or no user_query. Capped at
90
- # 2000 chars so the follow-up prompt stays bounded.
152
+ # end, skipping hook-generated turns (see above), and return the first real human
153
+ # turn's text. Returns '' if there is no transcript or no human user_query. Capped
154
+ # at 2000 chars so the follow-up prompt stays bounded.
91
155
  #
92
156
  # This is the Tier 0 intent-trace primitive: the final-review hook prepends the
93
157
  # extracted request to its followup so the model must trace every diff hunk back
@@ -97,6 +161,7 @@ function Get-LastUserQuery($obj) {
97
161
  if ($obj -and $obj.PSObject.Properties['transcript_path']) { $tp = [string]$obj.transcript_path }
98
162
  if (-not $tp -or -not (Test-Path -LiteralPath $tp)) { return '' }
99
163
  $lines = @(Get-Content -LiteralPath $tp -ErrorAction SilentlyContinue)
164
+ $embeddedFallback = ''
100
165
  for ($i = $lines.Count - 1; $i -ge 0; $i--) {
101
166
  $line = $lines[$i]
102
167
  if (-not $line -or $line -notmatch '"role"\s*:\s*"user"') { continue }
@@ -117,10 +182,22 @@ function Get-LastUserQuery($obj) {
117
182
  }
118
183
  if ($text -match '(?s)<user_query>\s*(.+?)\s*</user_query>') {
119
184
  $q = $Matches[1].Trim()
185
+ # Hook-generated turn -> not the human's words. Remember the embedded
186
+ # ORIGINAL REQUEST (from the most recent such turn) and keep walking.
187
+ if (Test-IsHookGeneratedQuery $q) {
188
+ if (-not $embeddedFallback) { $embeddedFallback = Get-EmbeddedOriginalRequest $q }
189
+ continue
190
+ }
120
191
  if ($q.Length -gt 2000) { $q = $q.Substring(0, 2000) + '...' }
121
192
  return (Redact-SecretsFromIntent $q)
122
193
  }
123
194
  }
195
+ # No real human turn survived in the transcript -> fall back to the request
196
+ # embedded in the latest hook followup, if we found one.
197
+ if ($embeddedFallback) {
198
+ if ($embeddedFallback.Length -gt 2000) { $embeddedFallback = $embeddedFallback.Substring(0, 2000) + '...' }
199
+ return (Redact-SecretsFromIntent $embeddedFallback)
200
+ }
124
201
  return ''
125
202
  }
126
203
 
@@ -76,16 +76,21 @@ if (Test-Path $latch) {
76
76
  # Already injected this turn -> quiet. Latch cleared at every stop.
77
77
  if (Test-Path $latch) { exit 0 }
78
78
 
79
- # --- current request (best-effort; absent in sandboxed runs) -----------------
80
- $currentQuery = Get-LastUserQuery $obj
79
+ # --- current request ---------------------------------------------------------
80
+ # PREFER the prompt stashed by intent-precompile (beforeSubmitPrompt): it is the
81
+ # verbatim request captured straight from the payload, available on the FIRST
82
+ # postToolUse of the turn and immune to <user_query> contamination. Fall back to
83
+ # transcript parsing only when the prompt hook did not run (older Cursor without
84
+ # beforeSubmitPrompt, or a sandboxed run). Both sources share Get-Sha256Hex so
85
+ # the contract's _intent_hash is consistent across the two hooks.
86
+ $currentQuery = Get-StashedPrompt $cid
87
+ if ([string]::IsNullOrWhiteSpace($currentQuery)) { $currentQuery = Get-LastUserQuery $obj }
81
88
  $hasQuery = -not [string]::IsNullOrWhiteSpace($currentQuery)
82
89
 
83
90
  $currentHash = ''
84
91
  $promptChanged = $false
85
92
  if ($hasQuery) {
86
- $bytes = [System.Text.Encoding]::UTF8.GetBytes($currentQuery)
87
- $hasher = [System.Security.Cryptography.SHA256]::Create()
88
- $currentHash = -join ($hasher.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') })
93
+ $currentHash = Get-Sha256Hex $currentQuery
89
94
  $prevHash = ''
90
95
  if (Test-Path $hashFile) { $prevHash = (Get-Content $hashFile -Raw -ErrorAction SilentlyContinue).Trim() }
91
96
  $promptChanged = ($currentHash -ne $prevHash)
@@ -127,12 +132,14 @@ if (Test-Path -LiteralPath $scopePath) {
127
132
  if ([string]::IsNullOrWhiteSpace($scopeFiles)) { $scopeFiles = '(none yet - auto-tracked as you edit)' }
128
133
  $scopeExists = $true
129
134
  $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.
135
+ # Hollow = no real intent on disk: empty, the hook's <TODO> placeholder, OR
136
+ # hook-generated review boilerplate that a stale extractor locked in as the
137
+ # intent (the contamination loop - "FINAL REVIEW (end of implementation)...").
131
138
  # 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>).
139
+ # fills it; scope-gate appends files to it and final-review audits against garbage).
133
140
  # Treat it as unusable: regenerate when we can read the request, else hand the agent
134
141
  # the pre-compile demand to write a real one.
135
- $scopeHollow = ([string]::IsNullOrWhiteSpace($scopeIntent) -or $scopeIntent -match '^\s*<TODO')
142
+ $scopeHollow = ([string]::IsNullOrWhiteSpace($scopeIntent) -or $scopeIntent -match '^\s*<TODO' -or (Test-IsHookGeneratedQuery $scopeIntent))
136
143
  # Staleness, hash-agnostic so it survives MODEL-written contracts:
137
144
  # - hook-written (has _intent_hash): stale when that hash != current query hash.
138
145
  # - model-written (no _intent_hash - the legacy pre-compile.md schema): we cannot
@@ -178,10 +185,11 @@ if ($shouldCreate -or $shouldRegen) {
178
185
  try {
179
186
  $intentVal = $currentQuery
180
187
  $traceQuery = $currentQuery
188
+ $defaultAcceptance = Get-DefaultAcceptance
181
189
  $scaffold = [ordered]@{
182
190
  intent = $intentVal
183
191
  files = @()
184
- acceptance = '<TODO: the one deterministic check that decides done>'
192
+ acceptance = $defaultAcceptance
185
193
  allow_growth = $false
186
194
  trace = [ordered]@{ query = $traceQuery; ts = (Get-Date).ToString('o') }
187
195
  _intent_hash = $currentHash
@@ -190,7 +198,7 @@ if ($shouldCreate -or $shouldRegen) {
190
198
  $json = $scaffold | ConvertTo-Json -Depth 8
191
199
  [System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
192
200
  $scopeIntent = $intentVal
193
- $scopeAcceptance = '<TODO: the one deterministic check that decides done>'
201
+ $scopeAcceptance = $defaultAcceptance
194
202
  $scopeFiles = '(auto-tracked - the scope hook records every file you edit)'
195
203
  $scopeExists = $true
196
204
  $scopeStale = $false
@@ -223,6 +231,15 @@ if ($needsHeal -and -not $regenerated) {
223
231
  # to scaffold from), or re-injecting an existing current contract.
224
232
  $queryLine = if ($hasQuery) { $currentQuery } else { '(current request unavailable - no transcript in this event)' }
225
233
 
234
+ # acceptance is seeded with a real default (never a bare <TODO>), but the default
235
+ # is generic - the contract is not "done being set up" until the agent sharpens it
236
+ # to the ONE deterministic check. Detect the unsharpened state and make it a loud,
237
+ # repeated demand each turn until the agent replaces it.
238
+ $acceptanceUnsharpened = ($scopeAcceptance -match '^\s*<TODO' -or $scopeAcceptance -eq (Get-DefaultAcceptance) -or $scopeAcceptance -match 'Sharpen to the one deterministic check')
239
+ $acceptanceDemand = if ($acceptanceUnsharpened) {
240
+ "`n`n>> acceptance is still the seeded default. Your FIRST action this turn is a targeted string-replace on .scope.json setting acceptance to the one deterministic check that decides done - then do the work."
241
+ } else { '' }
242
+
226
243
  if ($regenerated) {
227
244
  $msg = @"
228
245
  INTENT ANCHOR (scope regenerated) - .scope.json written for this prompt.
@@ -233,9 +250,9 @@ INTENT ANCHOR (scope regenerated) - .scope.json written for this prompt.
233
250
 
234
251
  The hook wrote a fresh scaffold to $scopePath from your current request. intent
235
252
  is locked from what you just asked. files[] is AUTO-TRACKED - the scope hook
236
- records every file you edit, so do not maintain it by hand. Set acceptance to
237
- the one deterministic check that decides done, THEN proceed. This contract will
238
- be re-injected every turn until your request changes again.
253
+ records every file you edit, so do not maintain it by hand. acceptance is seeded
254
+ with a sensible default; sharpen it to the one deterministic check, THEN proceed.
255
+ This contract will be re-injected every turn until your request changes again.$acceptanceDemand
239
256
  "@
240
257
  } elseif (-not $scopeExists -or $scopeHollow) {
241
258
  $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" }
@@ -269,7 +286,7 @@ INTENT ANCHOR (re-injected this turn from .scope.json) - your contract. Do not d
269
286
  acceptance: $scopeAcceptance
270
287
 
271
288
  $driftNote If a constraint above conflicts with what you are about to do, stop
272
- and reconcile - the contract outranks momentum.
289
+ and reconcile - the contract outranks momentum.$acceptanceDemand
273
290
  "@
274
291
  }
275
292
 
@@ -0,0 +1,104 @@
1
+ # intent-precompile.ps1 - beforeSubmitPrompt "contract first" writer (Cursor).
2
+ #
3
+ # THE FIX for "el .scope.json se crea casi al final": creation used to live on
4
+ # postToolUse (intent-anchor), which fires only AFTER the agent's first tool and
5
+ # depends on the transcript becoming readable to detect the prompt. Until then the
6
+ # PREVIOUS prompt's intent persisted and the agent worked under it - the scope
7
+ # only flipped to the right intent late in the turn.
8
+ #
9
+ # beforeSubmitPrompt fires "right after the user hits send, before the backend
10
+ # request" - the earliest possible moment, BEFORE the agent's first token - and
11
+ # its payload carries the user's `prompt` DIRECTLY (no <user_query> extraction, no
12
+ # transcript dependency, no hook-followup contamination). So this hook writes
13
+ # .scope.json with the real intent up front, making the contract the FIRST artifact
14
+ # of the turn, which the agent then governs by.
15
+ #
16
+ # It does TWO things, both deterministic:
17
+ # 1. STASH the verbatim prompt to current-prompt-<cid>.txt. intent-anchor prefers
18
+ # this over transcript parsing, so both hooks hash the SAME text and never
19
+ # fight over _intent_hash.
20
+ # 2. WRITE / REGENERATE .scope.json from the prompt when it is new (hash differs)
21
+ # or missing. Same prompt as on disk -> leave it (preserve the agent's refined
22
+ # intent / acceptance / files within the turn). acceptance is seeded with a
23
+ # real default (Get-DefaultAcceptance), never a bare <TODO>.
24
+ #
25
+ # Hook-generated submits (final-review / subagent-review followups that Cursor
26
+ # auto-submits) are SKIPPED: their prompt is review boilerplate, not the user's
27
+ # request - stashing or regenerating from them is the contamination loop.
28
+ #
29
+ # Never blocks submission (no `continue:false`); writes files as a side effect and
30
+ # exits 0. Disable: HOOKS_ENFORCE=0 or INTENT_ANCHOR_ENFORCE=0 (shares the
31
+ # intent-anchor kill switch - they are one subsystem).
32
+
33
+ $ErrorActionPreference = 'SilentlyContinue'
34
+ . "$PSScriptRoot\hook-common.ps1"
35
+
36
+ if ($env:HOOKS_ENFORCE -eq '0' -or $env:INTENT_ANCHOR_ENFORCE -eq '0') { exit 0 }
37
+
38
+ $obj = Read-HookStdinJson
39
+ if (-not $obj) { exit 0 }
40
+
41
+ # --- the prompt (direct from payload - the whole point of this event) --------
42
+ $prompt = ''
43
+ if ($obj.PSObject.Properties['prompt']) { $prompt = [string]$obj.prompt }
44
+ $prompt = $prompt.Trim()
45
+ if ([string]::IsNullOrWhiteSpace($prompt)) { exit 0 }
46
+
47
+ # Auto-submitted hook followups (FINAL REVIEW / SUBAGENT / SELF-REVIEW / INTENT
48
+ # ANCHOR) are not the user's request. Leave the real contract intact.
49
+ if (Test-IsHookGeneratedQuery $prompt) { exit 0 }
50
+
51
+ $prompt = Redact-SecretsFromIntent $prompt
52
+
53
+ $cid = Get-SafeConversationId $obj
54
+ $pendingDir = Get-HooksPendingDir
55
+
56
+ # --- repo root (workspace_roots / cwd; NO $HOME fallback - no ghost files) ----
57
+ $root = ''
58
+ $cands = @()
59
+ if ($obj.PSObject.Properties['cwd'] -and $obj.cwd) { $cands += [string]$obj.cwd }
60
+ if ($obj.PSObject.Properties['workspace_roots']) { foreach ($w in $obj.workspace_roots) { $cands += [string]$w } }
61
+ foreach ($c in $cands) { $f = ConvertTo-FwdPath $c; if ($f -and (Test-Path -LiteralPath $f)) { $root = $f.TrimEnd('/'); break } }
62
+ if (-not $root -and $env:CURSOR_PROJECT_DIR) {
63
+ $cpd = $env:CURSOR_PROJECT_DIR.Replace('\', '/').TrimEnd('/')
64
+ if (Test-Path -LiteralPath $cpd) { $root = $cpd }
65
+ }
66
+ if (-not $root) { exit 0 }
67
+
68
+ # --- stash the prompt so intent-anchor reads the same ground-truth text -------
69
+ try {
70
+ New-Item -ItemType Directory -Path $pendingDir -Force | Out-Null
71
+ [System.IO.File]::WriteAllText((Get-CurrentPromptPath $cid), $prompt, [System.Text.UTF8Encoding]::new($false))
72
+ } catch { }
73
+
74
+ # --- write / regenerate .scope.json (hash-gated) ------------------------------
75
+ $currentHash = Get-Sha256Hex $prompt
76
+ $scopePath = Join-Path $root '.scope.json'
77
+ $onDiskHash = ''
78
+ if (Test-Path -LiteralPath $scopePath) {
79
+ try {
80
+ $sj = Get-Content -LiteralPath $scopePath -Raw | ConvertFrom-Json
81
+ if ($sj.PSObject.Properties['_intent_hash']) { $onDiskHash = [string]$sj._intent_hash }
82
+ } catch { $onDiskHash = '' } # malformed -> regenerate
83
+ }
84
+
85
+ # Same prompt already locked (by a prior fire this turn, or by the agent who may
86
+ # have refined intent/acceptance/files) -> leave it. Only (re)write on a NEW or
87
+ # missing/garbage contract.
88
+ if ($onDiskHash -eq $currentHash) { exit 0 }
89
+
90
+ try {
91
+ $scaffold = [ordered]@{
92
+ intent = $prompt
93
+ files = @()
94
+ acceptance = (Get-DefaultAcceptance)
95
+ allow_growth = $false
96
+ trace = [ordered]@{ query = $prompt; ts = (Get-Date).ToString('o') }
97
+ _intent_hash = $currentHash
98
+ _generated_by = 'intent-precompile hook (beforeSubmitPrompt)'
99
+ }
100
+ $json = $scaffold | ConvertTo-Json -Depth 8
101
+ [System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
102
+ } catch { }
103
+
104
+ exit 0
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "version": 1,
3
3
  "hooks": {
4
+ "beforeSubmitPrompt": [
5
+ {
6
+ "command": "pwsh.exe -NoProfile -File ~/.agents/hooks/intent-precompile.ps1",
7
+ "timeout": 5,
8
+ "_comment": "5s: CONTRACT FIRST. Fires right after the user hits send, BEFORE the agent's first token, with the user's `prompt` in the payload directly. Writes/regenerates .scope.json (intent locked from the prompt, acceptance seeded with a real default - never a bare <TODO>) so the contract is the FIRST artifact of the turn instead of appearing late once postToolUse can read the transcript. Also stashes the verbatim prompt to current-prompt-<cid>.txt so intent-anchor (postToolUse) re-injects from the SAME ground-truth text - no <user_query> contamination, no _intent_hash fights. Skips hook-generated auto-submits (FINAL REVIEW / SUBAGENT / SELF-REVIEW). Hash-gated: same prompt on disk -> left intact (preserves the agent's refined intent/acceptance/files). Never blocks submission, always exits 0. Disable: HOOKS_ENFORCE=0 or INTENT_ANCHOR_ENFORCE=0."
9
+ }
10
+ ],
4
11
  "sessionStart": [
5
12
  {
6
13
  "command": "pwsh.exe -NoProfile -File ~/.cursor/inject-doctrine.ps1",
@@ -53,23 +53,30 @@ Answer these four, terse, in your first response. One phrase each, not prose:
53
53
 
54
54
  ## Materialize it: .scope.json (the hook owns this file)
55
55
 
56
- The `intent-anchor` hook creates and maintains `.scope.json` in the repo root for
57
- you, automatically, on the first tool of every turn:
58
- - `intent` is seeded from your verbatim request and REFRESHED when the request
59
- changesa new prompt regenerates the contract and resets `files[]`, so it
60
- never carries over between features;
56
+ The contract is written for you **before your first token**: the `intent-precompile`
57
+ hook fires on `beforeSubmitPrompt` (right after the user hits send) and writes
58
+ `.scope.json` to the repo root with the real `intent` already locked from the
59
+ requestso the contract is the FIRST artifact of the turn, and you govern by it
60
+ from the very first action. `intent-anchor` then re-injects it on every tool
61
+ boundary to keep it in focus.
62
+ - `intent` is locked from the request and REFRESHED when the request changes — a
63
+ new prompt regenerates the contract and resets `files[]`, so it never carries
64
+ over between features;
61
65
  - `files[]` is auto-recorded — the scope hook appends every file you edit, so
62
66
  you never maintain it by hand;
67
+ - `acceptance` is SEEDED with a real default (never a bare `<TODO>`); it is not a
68
+ blank you must fill, it is a draft you SHARPEN;
63
69
  - `trace.query` is the VERBATIM request (the audit anchor), `_intent_hash` and
64
70
  `_generated_by` are hook bookkeeping. Leave all three alone.
65
71
 
66
72
  Your two targeted edits on the contract (each a string replace on ONE field, never
67
- a whole-file rewrite):
68
- - **`intent`** → replace the verbatim seed with your Step 0 restatement: the
69
- normalized, meaning-preserving sentence. This is what final-review axis 0
70
- traces each diff hunk against, so a clean `intent` makes the audit sharp.
71
- - **`acceptance`** → set the single deterministic check that decides done, which
72
- the hook cannot derive.
73
+ a whole-file rewrite), done as your FIRST actions this turn before editing source:
74
+ - **`intent`** → replace the seed with your Step 0 restatement: the normalized,
75
+ meaning-preserving sentence. This is what final-review axis 0 traces each diff
76
+ hunk against, so a clean `intent` makes the audit sharp.
77
+ - **`acceptance`** → sharpen the seeded default to the single deterministic check
78
+ that decides done, which the hook cannot derive. The hook re-injects a loud
79
+ demand every turn until you do.
73
80
 
74
81
  Do **NOT** touch `trace.query`, `_intent_hash`, or `_generated_by`, and do **NOT**
75
82
  rewrite the whole file: `_intent_hash` is computed from the verbatim `trace.query`,
@@ -82,11 +89,11 @@ changed the meaning: `intent` and `trace.query` must agree.
82
89
  {
83
90
  "intent": "<YOU refine this: your normalized Step 0 restatement>",
84
91
  "files": ["<auto-recorded by the hook as you edit>"],
85
- "acceptance": "<YOU set this: the deterministic check that decides done>",
92
+ "acceptance": "<seeded with a default; YOU sharpen to the deterministic check>",
86
93
  "allow_growth": false,
87
94
  "trace": { "query": "<VERBATIM request - the hook owns this, leave it>", "ts": "<when>" },
88
95
  "_intent_hash": "<hook bookkeeping>",
89
- "_generated_by":"intent-anchor hook"
96
+ "_generated_by":"intent-precompile / intent-anchor hook"
90
97
  }
91
98
  ```
92
99