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 +13 -9
- package/README.md +12 -12
- package/linux/USER-RULES.md +9 -7
- package/linux/hooks/hook-common.sh +87 -12
- package/linux/hooks/intent-anchor.sh +39 -23
- package/linux/hooks/intent-precompile.sh +114 -0
- package/linux/hooks.json +7 -0
- package/linux/pre-compile.md +20 -13
- package/package.json +1 -1
- package/windows/USER-RULES.md +9 -7
- package/windows/hooks/anti-slop-audit.ps1 +88 -1
- package/windows/hooks/anti-slop.md +24 -0
- package/windows/hooks/hook-common.ps1 +81 -4
- package/windows/hooks/intent-anchor.ps1 +31 -14
- package/windows/hooks/intent-precompile.ps1 +104 -0
- package/windows/hooks.json +7 -0
- package/windows/pre-compile.md +20 -13
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-
|
|
80
|
-
# Needs a repo root
|
|
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
|
-
|
|
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-
|
|
101
|
-
# Needs a repo root
|
|
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
|
-
|
|
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.
|
|
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>
|
|
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-
|
|
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
|
-
|
|
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
|
-
###
|
|
97
|
+
### Contract first, then kept alive: `intent-precompile` + `intent-anchor`
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
The contract has to exist *before* the agent edits and stay in focus *as* it edits. Two hooks, two jobs:
|
|
100
100
|
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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
|
package/linux/USER-RULES.md
CHANGED
|
@@ -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
|
|
10
|
-
|
|
11
|
-
operating contract, not optional:
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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 -
|
|
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
|
|
78
|
-
|
|
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
|
-
|
|
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,
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
#
|
|
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
|
-
|
|
198
|
-
#
|
|
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
|
|
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": "
|
|
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="
|
|
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.
|
|
282
|
-
the one deterministic check
|
|
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",
|
package/linux/pre-compile.md
CHANGED
|
@@ -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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
request — so 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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
- **`acceptance`** →
|
|
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
|
|
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
|
+
"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"
|
package/windows/USER-RULES.md
CHANGED
|
@@ -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
|
|
10
|
-
|
|
11
|
-
operating contract, not optional:
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 (
|
|
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
|
-
#
|
|
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,
|
|
89
|
-
#
|
|
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
|
|
80
|
-
|
|
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
|
-
$
|
|
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,
|
|
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
|
|
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 =
|
|
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 =
|
|
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.
|
|
237
|
-
the one deterministic check
|
|
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
|
package/windows/hooks.json
CHANGED
|
@@ -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",
|
package/windows/pre-compile.md
CHANGED
|
@@ -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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
request — so 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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
- **`acceptance`** →
|
|
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
|
|
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
|
|