cursordoctrine 0.6.3 → 0.6.4

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.
@@ -105,7 +105,7 @@ resolve_agent_path() {
105
105
  esac
106
106
  }
107
107
 
108
- # extract_last_user_query <json> -> text of the last <user_query> in this
108
+ # extract_last_user_query <json> -> text of the last *human* <user_query> in this
109
109
  # conversation's transcript, or '' if there is none. Capped at 2000 chars.
110
110
  #
111
111
  # This is the Tier 0 intent-trace primitive: the final-review hook prepends the
@@ -113,7 +113,14 @@ resolve_agent_path() {
113
113
  # to it. Anything untraceable is a hallucinated requirement.
114
114
  #
115
115
  # 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.
116
+ # the first (last) user record whose content carries a <user_query> tag - SKIPPING
117
+ # hook-generated turns. final-review.sh / subagent-stop-review.sh emit a
118
+ # {followup_message} that Cursor replays as a user turn (and self-review /
119
+ # intent-anchor inject into additional_context); returning one of those would lock
120
+ # the review boilerplate into .scope.json as the intent (the contamination loop).
121
+ # Hook turns are detected by their fixed headers; if the transcript has been
122
+ # trimmed to just the hook turn, recover the real request from the embedded
123
+ # "ORIGINAL REQUEST ... --- <request> ---" block.
117
124
  extract_last_user_query() {
118
125
  local json="$1"
119
126
  local tp
@@ -134,6 +141,14 @@ extract_last_user_query() {
134
141
  if have_py; then
135
142
  printf '%s' "$reversed" | python3 -c '
136
143
  import json, re, sys
144
+ HOOK_HDR = re.compile(r"^\s*(FINAL REVIEW \(end of implementation\)|SUBAGENT FINAL REVIEW|SELF-REVIEW|INTENT ANCHOR)", re.M)
145
+ EMBEDDED = re.compile(r"ORIGINAL REQUEST[^\r\n]*\r?\n-{3,}\r?\n(.+?)\r?\n-{3,}", re.S)
146
+ def redact(q):
147
+ q = re.sub(r"\bnpm_[A-Za-z0-9]{10,}\b", "[REDACTED_NPM_TOKEN]", q)
148
+ 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)
149
+ q = re.sub(r"(?i)(api[_-]?key|token|secret|password)\s*[:=]\s*\S+", r"\1=[REDACTED]", q)
150
+ return q
151
+ embedded_fallback = ""
137
152
  try:
138
153
  for line in sys.stdin:
139
154
  line = line.strip()
@@ -155,15 +170,26 @@ try:
155
170
  if isinstance(p, dict) and p.get("type") == "text" and p.get("text"):
156
171
  text += p["text"]
157
172
  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
173
+ if not m:
174
+ continue
175
+ q = m.group(1).strip()
176
+ # Hook-generated turn -> not the human words. Remember the embedded
177
+ # ORIGINAL REQUEST (latest such turn) and keep walking back.
178
+ if HOOK_HDR.search(q):
179
+ if not embedded_fallback:
180
+ em = EMBEDDED.search(q)
181
+ if em:
182
+ embedded_fallback = em.group(1).strip()
183
+ continue
184
+ if len(q) > 2000:
185
+ q = q[:2000] + "..."
186
+ print(redact(q))
187
+ break
188
+ else:
189
+ if embedded_fallback:
190
+ if len(embedded_fallback) > 2000:
191
+ embedded_fallback = embedded_fallback[:2000] + "..."
192
+ print(redact(embedded_fallback))
167
193
  except Exception:
168
194
  pass
169
195
  ' 2>/dev/null
@@ -172,9 +198,14 @@ except Exception:
172
198
 
173
199
  # No python3: best-effort grep for the common case where the user message
174
200
  # is the only place <user_query> appears in a line. Imperfect but bounded.
201
+ # Drop hook-generated turns (FINAL REVIEW / SUBAGENT / SELF-REVIEW / INTENT
202
+ # ANCHOR) so we never lock review boilerplate as the intent; take the first
203
+ # surviving human <user_query>.
175
204
  printf '%s' "$reversed" |
176
- grep -m1 -oE '<user_query>[^<]*</user_query>' 2>/dev/null |
205
+ grep -oE '<user_query>[^<]*</user_query>' 2>/dev/null |
177
206
  sed -E 's@</?user_query>@@g' |
207
+ grep -vE '^[[:space:]]*(FINAL REVIEW \(end of implementation\)|SUBAGENT FINAL REVIEW|SELF-REVIEW|INTENT ANCHOR)' 2>/dev/null |
208
+ head -n1 |
178
209
  sed -E 's/\bnpm_[A-Za-z0-9]{10,}\b/[REDACTED_NPM_TOKEN]/g' |
179
210
  head -c 2000
180
211
  }
@@ -150,12 +150,14 @@ EOF
150
150
  # changed (or a new session) => regenerate and RESET files[] (the "arrastre entre
151
151
  # features" fix). Same prompt this session => heal in place (backfill bookkeeping, keep
152
152
  # files[]/acceptance) so the NEXT prompt is detected by hash.
153
- # Hollow = no real intent on disk: empty, or still the hook's <TODO> placeholder.
154
- # A hollow contract is worse than none (it looks owned, so neither hook nor agent
155
- # fills it). Treat it as unusable: regenerate when the request is readable, else
156
- # hand the agent the pre-compile demand to author a real one.
153
+ # Hollow = no real intent on disk: empty, the hook's <TODO> placeholder, OR
154
+ # hook-generated review boilerplate that a stale extractor locked in (the
155
+ # contamination loop - "FINAL REVIEW (end of implementation)..."). A hollow
156
+ # contract is worse than none (it looks owned, so neither hook nor agent fills
157
+ # it). Treat it as unusable: regenerate when the request is readable, else hand
158
+ # the agent the pre-compile demand to author a real one.
157
159
  case "$scope_intent" in
158
- ""|"<TODO"*) scope_hollow=1 ;;
160
+ ""|"<TODO"*|"FINAL REVIEW (end of implementation)"*|"SUBAGENT FINAL REVIEW"*|"SELF-REVIEW"*|"INTENT ANCHOR"*) scope_hollow=1 ;;
159
161
  esac
160
162
  if [ "$scope_exists" = "1" ] && [ "$has_query" = "1" ]; then
161
163
  if [ "$scope_hollow" = "1" ]; then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursordoctrine",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
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"
@@ -83,11 +83,36 @@ function Redact-SecretsFromIntent([string]$text) {
83
83
  return $text
84
84
  }
85
85
 
86
- # Extract the last user <user_query> from a Cursor transcript JSONL.
86
+ # A <user_query> turn can actually be a HOOK-GENERATED message replayed by Cursor
87
+ # as a user turn: final-review.ps1 and subagent-stop-review.ps1 emit a
88
+ # {followup_message} that Cursor auto-submits as the next user turn, and the
89
+ # self-review / intent-anchor injections get drained into additional_context.
90
+ # If Get-LastUserQuery returns one of those, intent-anchor locks the REVIEW
91
+ # BOILERPLATE into .scope.json as the "intent" (the contamination loop that put
92
+ # "FINAL REVIEW (end of implementation)..." in the contract). Detect them by the
93
+ # fixed headers the hooks emit and skip past them to the real human turn.
94
+ function Test-IsHookGeneratedQuery([string]$text) {
95
+ if (-not $text) { return $false }
96
+ return ($text -match '(?m)^\s*(FINAL REVIEW \(end of implementation\)|SUBAGENT FINAL REVIEW|SELF-REVIEW|INTENT ANCHOR)')
97
+ }
98
+
99
+ # The final-review / subagent-review followups embed the real human request as:
100
+ # ORIGINAL REQUEST (...):\n---\n<request>\n---
101
+ # When the transcript has been trimmed to just the hook turn, recover the human
102
+ # request from that block instead of returning the boilerplate.
103
+ function Get-EmbeddedOriginalRequest([string]$text) {
104
+ if (-not $text) { return '' }
105
+ if ($text -match '(?s)ORIGINAL REQUEST[^\r\n]*\r?\n-{3,}\r?\n(.+?)\r?\n-{3,}') {
106
+ return $Matches[1].Trim()
107
+ }
108
+ return ''
109
+ }
110
+
111
+ # Extract the last *human* user <user_query> from a Cursor transcript JSONL.
87
112
  # 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.
113
+ # end, skipping hook-generated turns (see above), and return the first real human
114
+ # turn's text. Returns '' if there is no transcript or no human user_query. Capped
115
+ # at 2000 chars so the follow-up prompt stays bounded.
91
116
  #
92
117
  # This is the Tier 0 intent-trace primitive: the final-review hook prepends the
93
118
  # extracted request to its followup so the model must trace every diff hunk back
@@ -97,6 +122,7 @@ function Get-LastUserQuery($obj) {
97
122
  if ($obj -and $obj.PSObject.Properties['transcript_path']) { $tp = [string]$obj.transcript_path }
98
123
  if (-not $tp -or -not (Test-Path -LiteralPath $tp)) { return '' }
99
124
  $lines = @(Get-Content -LiteralPath $tp -ErrorAction SilentlyContinue)
125
+ $embeddedFallback = ''
100
126
  for ($i = $lines.Count - 1; $i -ge 0; $i--) {
101
127
  $line = $lines[$i]
102
128
  if (-not $line -or $line -notmatch '"role"\s*:\s*"user"') { continue }
@@ -117,10 +143,22 @@ function Get-LastUserQuery($obj) {
117
143
  }
118
144
  if ($text -match '(?s)<user_query>\s*(.+?)\s*</user_query>') {
119
145
  $q = $Matches[1].Trim()
146
+ # Hook-generated turn -> not the human's words. Remember the embedded
147
+ # ORIGINAL REQUEST (from the most recent such turn) and keep walking.
148
+ if (Test-IsHookGeneratedQuery $q) {
149
+ if (-not $embeddedFallback) { $embeddedFallback = Get-EmbeddedOriginalRequest $q }
150
+ continue
151
+ }
120
152
  if ($q.Length -gt 2000) { $q = $q.Substring(0, 2000) + '...' }
121
153
  return (Redact-SecretsFromIntent $q)
122
154
  }
123
155
  }
156
+ # No real human turn survived in the transcript -> fall back to the request
157
+ # embedded in the latest hook followup, if we found one.
158
+ if ($embeddedFallback) {
159
+ if ($embeddedFallback.Length -gt 2000) { $embeddedFallback = $embeddedFallback.Substring(0, 2000) + '...' }
160
+ return (Redact-SecretsFromIntent $embeddedFallback)
161
+ }
124
162
  return ''
125
163
  }
126
164
 
@@ -127,12 +127,14 @@ if (Test-Path -LiteralPath $scopePath) {
127
127
  if ([string]::IsNullOrWhiteSpace($scopeFiles)) { $scopeFiles = '(none yet - auto-tracked as you edit)' }
128
128
  $scopeExists = $true
129
129
  $scopeHasHash = ($sj.PSObject.Properties['_intent_hash'] -and -not [string]::IsNullOrWhiteSpace([string]$sj._intent_hash))
130
- # Hollow = no real intent on disk: empty, or still the hook's <TODO> placeholder.
130
+ # Hollow = no real intent on disk: empty, the hook's <TODO> placeholder, OR
131
+ # hook-generated review boilerplate that a stale extractor locked in as the
132
+ # intent (the contamination loop - "FINAL REVIEW (end of implementation)...").
131
133
  # 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>).
134
+ # fills it; scope-gate appends files to it and final-review audits against garbage).
133
135
  # Treat it as unusable: regenerate when we can read the request, else hand the agent
134
136
  # the pre-compile demand to write a real one.
135
- $scopeHollow = ([string]::IsNullOrWhiteSpace($scopeIntent) -or $scopeIntent -match '^\s*<TODO')
137
+ $scopeHollow = ([string]::IsNullOrWhiteSpace($scopeIntent) -or $scopeIntent -match '^\s*<TODO' -or (Test-IsHookGeneratedQuery $scopeIntent))
136
138
  # Staleness, hash-agnostic so it survives MODEL-written contracts:
137
139
  # - hook-written (has _intent_hash): stale when that hash != current query hash.
138
140
  # - model-written (no _intent_hash - the legacy pre-compile.md schema): we cannot