cursordoctrine 0.4.3 → 0.4.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.
package/README.md CHANGED
@@ -98,10 +98,11 @@ The Anchor Set is skipped for trivial one-liners (typo, literal) — the `declar
98
98
 
99
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. This is the failure mode the nudge alone can't fix (a reminder that the contract exists ≠ the contract being in context).
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-anchor` (`postToolUse`, registered first so it runs before `post-tool-use` drains the bus) does three things on the **first tool boundary of every turn** (per-turn latch, cleared unconditionally at each stop):
102
102
 
103
- 1. **Re-inject the contract.** 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's work **before** edits pile up and dilute it. This runs unconditionally (no transcript needed); it's the core anti-dilution move.
104
- 2. **Re-compile on prompt change.** Hashes the current `<user_query>` and compares to the previous turn's hash (`last-query-<cid>.hash`, which persists across turns). If the request moved, it demands the agent **update** `.scope.json` to match the scope tracks the request. If no `.scope.json` exists, it demands one be written. (Needs `transcript_path` in the payload; if absent this part degrades to silent but the re-injection still runs.)
103
+ 1. **Materialize the contract (0.4.4+).** If `.scope.json` is missing or invalid and the current `<user_query>` is available, the hook **writes a scaffold to disk** `intent` from your prompt, `files`/`acceptance` as obvious `<TODO: …>` placeholders. Contract creation is no longer probabilistic.
104
+ 2. **Re-inject the contract.** Reads `.scope.json` and stashes `intent` + `files` + `acceptance` into the feedback bus, which `post-tool-use` delivers as `additional_context`.
105
+ 3. **Re-compile on prompt change.** Hashes the current `<user_query>` and compares to the previous turn's hash. If the request moved and a non-scaffold contract already exists, it demands the agent **update** `.scope.json`.
105
106
 
106
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.
107
108
 
package/bin/cli.mjs CHANGED
@@ -74,6 +74,26 @@ function mergeHooks(existing, incoming, keys) {
74
74
  if (i >= 0) cur[i] = entry;
75
75
  else cur.push(entry);
76
76
  }
77
+ // Re-order our entries to match the shipped hooks.json (merge used to leave
78
+ // stale order — e.g. post-tool-use before intent-anchor — breaking same-tool
79
+ // delivery of the anchor message).
80
+ const foreign = cur.filter((x) => x && !isOurs(x.command, keys));
81
+ const reordered = [];
82
+ const used = new Set();
83
+ for (const entry of entries) {
84
+ const k = keyOf(entry.command, keys);
85
+ if (!k || !isOurs(entry.command, keys)) continue;
86
+ const found = cur.find((x) => x && keyOf(x.command, keys) === k);
87
+ if (found) {
88
+ reordered.push(found);
89
+ used.add(k);
90
+ }
91
+ }
92
+ for (const x of cur) {
93
+ const k = keyOf(x?.command, keys);
94
+ if (isOurs(x?.command, keys) && k && !used.has(k)) reordered.push(x);
95
+ }
96
+ out.hooks[event] = [...reordered, ...foreign];
77
97
  }
78
98
  let preserved = 0;
79
99
  for (const entries of Object.values(out.hooks)) {
@@ -300,42 +320,62 @@ function verify() {
300
320
  return true;
301
321
  });
302
322
 
303
- check('intent-anchor re-injects .scope.json every turn; latch re-arms at stop', () => {
304
- // intent-anchor appends to feedback-<cid>.txt (the shared bus); drain via
305
- // post-tool-use the same way the harness delivers additional_context.
323
+ check('intent-anchor scaffolds .scope.json and re-injects every turn', () => {
306
324
  const drainedOf = (cidv) => runHook(hook('post-tool-use'), { conversation_id: cidv });
307
325
  const anchorCid = 'npxv4';
308
326
  const scopePath = join(HOME, '.scope.json');
327
+ const transcriptPath = join(HOME, '.cursor', '.hooks-pending', 'verify-transcript-npxv4.jsonl');
328
+ const testQuery = 'fix grid symmetry and color tokens';
309
329
 
310
- const cleanup = () => { try { rmSync(scopePath, { force: true }); } catch {} };
330
+ const cleanup = () => {
331
+ try { rmSync(scopePath, { force: true }); } catch {}
332
+ try { rmSync(transcriptPath, { force: true }); } catch {}
333
+ };
311
334
  cleanup();
312
335
 
313
- // --- Case A: no .scope.json -> demand one be written --------------
314
- runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: HOME });
336
+ // Fake transcript so Get-LastUserQuery / scaffold can read the request.
337
+ const transcriptLine = JSON.stringify({
338
+ role: 'user',
339
+ message: { content: `<user_query>${testQuery}</user_query>` },
340
+ });
341
+ writeFileSync(transcriptPath, transcriptLine + '\n', 'utf8');
342
+
343
+ const anchorPayload = { conversation_id: anchorCid, cwd: HOME, transcript_path: transcriptPath };
344
+
345
+ // --- Case A: no .scope.json -> hook writes scaffold on disk ----------
346
+ runHook(hook('intent-anchor'), anchorPayload);
315
347
  let d = drainedOf(anchorCid);
316
- if (!d.includes('INTENT ANCHOR') || !d.includes('NOT compiled your Anchor Set')) {
317
- cleanup(); return { ok: false, detail: 'no-scope branch did not demand compilation' };
348
+ if (!existsSync(scopePath)) {
349
+ cleanup(); return { ok: false, detail: '.scope.json was not written to disk' };
350
+ }
351
+ let scope;
352
+ try { scope = JSON.parse(readFileSync(scopePath, 'utf8')); } catch {
353
+ cleanup(); return { ok: false, detail: '.scope.json scaffold is not valid JSON' };
354
+ }
355
+ if (scope.intent !== testQuery) {
356
+ cleanup(); return { ok: false, detail: `scaffold intent mismatch: ${scope.intent}` };
357
+ }
358
+ if (!d.includes('scaffold written') || !d.includes(testQuery)) {
359
+ cleanup(); return { ok: false, detail: 'scaffold branch did not inject written contract' };
318
360
  }
319
361
 
320
- // --- Stop clears the latch (the regression path from 0.4.1) -------
362
+ // --- Stop clears the latch -------------------------------------------
321
363
  runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
322
364
 
323
- // --- Case B: scope exists -> re-inject contract every turn --------
365
+ // --- Case B: scope exists -> re-inject contract every turn -----------
324
366
  writeFileSync(scopePath, JSON.stringify({
325
367
  intent: 'fix grid symmetry and color tokens',
326
368
  files: ['src/grid.tsx'],
327
369
  acceptance: 'grid renders symmetric; tokens match palette',
328
370
  }));
329
- // Turn 2 (no transcript in sandbox -> query unavailable -> re-inject branch).
330
- runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: HOME });
371
+ runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: HOME, transcript_path: transcriptPath });
331
372
  d = drainedOf(anchorCid);
332
373
  if (!d.includes('fix grid symmetry and color tokens') || !d.includes('INTENT ANCHOR')) {
333
374
  cleanup(); return { ok: false, detail: 'contract not re-injected on turn 2' };
334
375
  }
335
376
 
336
- // --- Stop clears the latch again; turn 3 must re-inject too -------
337
377
  runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
338
- runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: HOME });
378
+ runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: HOME, transcript_path: transcriptPath });
339
379
  d = drainedOf(anchorCid);
340
380
  if (!d.includes('fix grid symmetry and color tokens')) {
341
381
  cleanup(); return { ok: false, detail: 'contract not re-injected on turn 3 (latch stranded at stop)' };
@@ -17,12 +17,12 @@
17
17
  #
18
18
  # 2. RE-COMPILE ON PROMPT CHANGE: hash the current <user_query> (via
19
19
  # extract_last_user_query, which reads the transcript) and compare to
20
- # last-query-<cid>.hash. If they differ, demand the agent UPDATE
21
- # .scope.json to match the new request. If no .scope.json exists, demand
22
- # one be written. The scope tracks the request - when the request moves,
23
- # the scope moves with it. This part needs transcript_path in the payload;
24
- # if it is absent the hook degrades to silent on change-detection but the
25
- # re-injection above still runs.
20
+ # last-query-<cid>.hash. If they differ and a valid .scope.json exists,
21
+ # demand the agent UPDATE it. If no valid .scope.json exists and the query
22
+ # is available, WRITE a deterministic scaffold to disk (intent = query,
23
+ # files/acceptance = TODO placeholders) so re-injection always has real
24
+ # content from the first tool boundary contract creation is not left to
25
+ # the LLM alone.
26
26
  #
27
27
  # Why postToolUse, not afterFileEdit: afterFileEdit only fires AFTER an edit
28
28
  # exists, and Cursor has no preToolUse for file edits. postToolUse fires after
@@ -115,6 +115,36 @@ EOF
115
115
  fi
116
116
  fi
117
117
 
118
+ # --- deterministic scaffold (0.4.4) -------------------------------------------
119
+ # When the query is available and there is no valid contract, write .scope.json
120
+ # on disk — intent from <user_query>, TODO placeholders for files/acceptance.
121
+ scaffold_written=0
122
+ should_scaffold=0
123
+ [ "$has_query" = "1" ] && [ "$scope_exists" != "1" ] && should_scaffold=1
124
+
125
+ if [ "$should_scaffold" = "1" ]; then
126
+ if have_py; then
127
+ if python3 -c '
128
+ import json, sys
129
+ path, intent = sys.argv[1], sys.argv[2]
130
+ obj = {
131
+ "intent": intent,
132
+ "files": ["<TODO: list files>"],
133
+ "acceptance": "<TODO: deterministic success check>",
134
+ "allow_growth": False,
135
+ }
136
+ with open(path, "w", encoding="utf-8") as f:
137
+ json.dump(obj, f, ensure_ascii=False)
138
+ ' "$scope_path" "$current_query" 2>/dev/null; then
139
+ scaffold_written=1
140
+ scope_exists=1
141
+ scope_intent="$current_query"
142
+ scope_acceptance="<TODO: deterministic success check>"
143
+ scope_files="<TODO: list files>"
144
+ fi
145
+ fi
146
+ fi
147
+
118
148
  # --- compose the anchor message ---------------------------------------------
119
149
  if [ "$has_query" = "1" ]; then
120
150
  query_line="$current_query"
@@ -122,7 +152,17 @@ else
122
152
  query_line="(current request unavailable - no transcript in this event)"
123
153
  fi
124
154
 
125
- if [ "$scope_exists" != "1" ]; then
155
+ if [ "$scaffold_written" = "1" ]; then
156
+ msg="INTENT ANCHOR (scaffold written to .scope.json) - contract materialized from your request.
157
+
158
+ intent: $scope_intent
159
+ files: $scope_files
160
+ acceptance: $scope_acceptance
161
+
162
+ The hook wrote this scaffold to $scope_path — intent is locked from your current
163
+ request. Replace the TODO placeholders with real files[] and acceptance before
164
+ editing source. The contract is on disk and will be re-injected every turn."
165
+ elif [ "$scope_exists" != "1" ]; then
126
166
  msg="INTENT ANCHOR (pre-compile) - no .scope.json found in $root.
127
167
 
128
168
  Current request:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursordoctrine",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Thin self-review hooks for Cursor — the model is the auditor. Proactive intent compilation (pre-compile Anchor Set + per-turn .scope.json re-injection against Salience Dilution), intent-trace final review (Tier 0), unified 13-item anti-slop checklist, operational slop detection.",
5
5
  "bin": {
6
6
  "cursordoctrine": "bin/cli.mjs"
@@ -16,12 +16,12 @@
16
16
  #
17
17
  # 2. RE-COMPILE ON PROMPT CHANGE: hash the current <user_query> (via
18
18
  # Get-LastUserQuery, which reads the transcript) and compare to
19
- # last-query-<cid>.hash. If they differ, demand the agent UPDATE
20
- # .scope.json to match the new request. If no .scope.json exists, demand
21
- # one be written. The scope tracks the request - when the request moves,
22
- # the scope moves with it. This part needs transcript_path in the payload;
23
- # if it is absent the hook degrades to silent on change-detection but the
24
- # re-injection above still runs.
19
+ # last-query-<cid>.hash. If they differ and a valid .scope.json exists,
20
+ # demand the agent UPDATE it. If no valid .scope.json exists and the query
21
+ # is available, WRITE a deterministic scaffold to disk (intent = query,
22
+ # files/acceptance = TODO placeholders) so re-injection always has real
23
+ # content from the first tool boundary contract creation is not left to
24
+ # the LLM alone.
25
25
  #
26
26
  # Why postToolUse, not afterFileEdit: afterFileEdit only fires AFTER an edit
27
27
  # exists, and Cursor has no preToolUse for file edits. postToolUse fires after
@@ -93,12 +93,53 @@ if (Test-Path -LiteralPath $scopePath) {
93
93
  } catch { $scopeExists = $false } # malformed JSON -> treat as missing
94
94
  }
95
95
 
96
+ # --- deterministic scaffold (0.4.4) -----------------------------------------
97
+ # When the query is available and there is no valid contract, the hook writes
98
+ # .scope.json itself — intent from <user_query>, obvious TODO placeholders
99
+ # for files/acceptance. Fires on prompt change (incl. first turn: empty prev
100
+ # hash) or whenever the contract is still missing on a turn boundary.
101
+ $scaffoldWritten = $false
102
+ $shouldScaffold = $hasQuery -and (-not $scopeExists)
103
+ if ($shouldScaffold) {
104
+ try {
105
+ $scaffold = [ordered]@{
106
+ intent = $currentQuery
107
+ files = @('<TODO: list files>')
108
+ acceptance = '<TODO: deterministic success check>'
109
+ allow_growth = $false
110
+ }
111
+ $json = $scaffold | ConvertTo-Json -Depth 4 -Compress
112
+ $dir = Split-Path -Parent $scopePath
113
+ if ($dir -and -not (Test-Path -LiteralPath $dir)) {
114
+ New-Item -ItemType Directory -Path $dir -Force | Out-Null
115
+ }
116
+ [System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
117
+ $scopeIntent = $currentQuery
118
+ $scopeAcceptance = '<TODO: deterministic success check>'
119
+ $scopeFiles = '<TODO: list files>'
120
+ $scopeExists = $true
121
+ $scaffoldWritten = $true
122
+ } catch { }
123
+ }
124
+
96
125
  # --- compose the anchor message ---------------------------------------------
97
126
  # Re-injection (req 2) is unconditional whenever a contract exists.
98
- # Recompile-demand (req 1) fires when there is no contract, or the prompt moved.
127
+ # Recompile-demand (req 1) fires when the prompt moved but a real contract exists.
99
128
  $queryLine = if ($hasQuery) { $currentQuery } else { '(current request unavailable - no transcript in this event)' }
100
129
 
101
- if (-not $scopeExists) {
130
+ if ($scaffoldWritten) {
131
+ $msg = @"
132
+ INTENT ANCHOR (scaffold written to .scope.json) - contract materialized from your request.
133
+
134
+ intent: $scopeIntent
135
+ files: $scopeFiles
136
+ acceptance: $scopeAcceptance
137
+
138
+ The hook wrote this scaffold to $scopePath — intent is locked from your current
139
+ request. Replace the TODO placeholders with real files[] and acceptance before
140
+ editing source. The contract is on disk and will be re-injected every turn.
141
+ "@
142
+ } elseif (-not $scopeExists) {
102
143
  $msg = @"
103
144
  INTENT ANCHOR (pre-compile) - no .scope.json found in $root.
104
145