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 +4 -3
- package/bin/cli.mjs +54 -14
- package/linux/hooks/intent-anchor.sh +47 -7
- package/package.json +1 -1
- package/windows/hooks/intent-anchor.ps1 +49 -8
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
|
|
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. **
|
|
104
|
-
2. **Re-
|
|
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
|
|
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 = () => {
|
|
330
|
+
const cleanup = () => {
|
|
331
|
+
try { rmSync(scopePath, { force: true }); } catch {}
|
|
332
|
+
try { rmSync(transcriptPath, { force: true }); } catch {}
|
|
333
|
+
};
|
|
311
334
|
cleanup();
|
|
312
335
|
|
|
313
|
-
//
|
|
314
|
-
|
|
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 (!
|
|
317
|
-
cleanup(); return { ok: false, detail: '
|
|
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
|
|
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
|
-
|
|
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
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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 [ "$
|
|
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
|
+
"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
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
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
|
|
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 (
|
|
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
|
|