cursordoctrine 0.4.4 → 0.4.6
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 +5 -4
- package/bin/cli.mjs +56 -36
- package/linux/hooks/intent-anchor.sh +81 -61
- package/package.json +2 -2
- package/windows/hooks/intent-anchor.ps1 +66 -62
package/README.md
CHANGED
|
@@ -98,11 +98,12 @@ 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 two things on the **first tool boundary of every turn** (per-turn latch, cleared unconditionally at each stop):
|
|
102
102
|
|
|
103
|
-
1. **Materialize the contract
|
|
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
|
-
|
|
103
|
+
1. **Materialize the contract per prompt.** When the current `<user_query>` differs from the contract on disk (no contract yet, or its recorded `_intent_hash` doesn't match), the hook **writes a fresh `.scope.json` to the repo root**: `intent` locked from the prompt, `files`/`acceptance` as `<TODO>` placeholders the agent fills in. Every new prompt → a fresh contract the agent works from. Same prompt → no rewrite.
|
|
104
|
+
2. **Re-inject the contract every turn.** Reads `.scope.json` and stashes `intent` + `files` + `acceptance` into the feedback bus, which `post-tool-use` delivers as `additional_context`. The contract is back in the model's attentional focus at the start of each turn, before edits pile up and dilute it.
|
|
105
|
+
|
|
106
|
+
> **The hook writes `.scope.json` deliberately.** This is the intended behavior: the contract must exist *before* the agent edits, and it must track the request. The agent fills the `<TODO>` placeholders (`files`, `acceptance`) on the first turn; the hook regenerates the scaffold when the prompt changes. Two real bugs in the earlier 0.4.4 build are fixed here: (a) **never writes to `$HOME`** — if the repo `cwd` can't be resolved the hook stays silent rather than drop a ghost file (bail instead of fallback); (b) **regenerates on prompt CHANGE, not on every turn** — staleness is tracked via `_intent_hash` stored in the file itself, so a same-prompt turn re-injects without rewriting.
|
|
106
107
|
|
|
107
108
|
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.
|
|
108
109
|
|
package/bin/cli.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
readFileSync,
|
|
18
18
|
readdirSync,
|
|
19
19
|
rmSync,
|
|
20
|
+
statSync,
|
|
20
21
|
writeFileSync,
|
|
21
22
|
} from 'node:fs';
|
|
22
23
|
import { join, resolve, dirname } from 'node:path';
|
|
@@ -320,65 +321,84 @@ function verify() {
|
|
|
320
321
|
return true;
|
|
321
322
|
});
|
|
322
323
|
|
|
323
|
-
check('intent-anchor scaffolds .scope.json
|
|
324
|
+
check('intent-anchor scaffolds .scope.json per-prompt, never to $HOME', () => {
|
|
325
|
+
// The user-requested behavior: every NEW prompt -> a fresh .scope.json
|
|
326
|
+
// in the repo root, intent locked from the query. Same prompt -> re-inject
|
|
327
|
+
// without rewriting. Never writes to $HOME (the 0.4.4 ghost-file bug).
|
|
328
|
+
// We drive it with a synthetic transcript so Get-LastUserQuery can read
|
|
329
|
+
// the <user_query>; the hook resolves cwd -> HOME (the repo root here).
|
|
324
330
|
const drainedOf = (cidv) => runHook(hook('post-tool-use'), { conversation_id: cidv });
|
|
325
331
|
const anchorCid = 'npxv4';
|
|
326
|
-
const
|
|
332
|
+
const repoDir = HOME; // verify() runs under a pinned HOME; treat it as the repo
|
|
333
|
+
const scopePath = join(repoDir, '.scope.json');
|
|
327
334
|
const transcriptPath = join(HOME, '.cursor', '.hooks-pending', 'verify-transcript-npxv4.jsonl');
|
|
328
|
-
const
|
|
335
|
+
const q1 = 'fix grid symmetry and color tokens';
|
|
336
|
+
const q2 = 'now add dark mode support';
|
|
329
337
|
|
|
330
338
|
const cleanup = () => {
|
|
331
339
|
try { rmSync(scopePath, { force: true }); } catch {}
|
|
332
340
|
try { rmSync(transcriptPath, { force: true }); } catch {}
|
|
333
341
|
};
|
|
334
342
|
cleanup();
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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);
|
|
343
|
+
const writeTranscript = (q) =>
|
|
344
|
+
writeFileSync(transcriptPath,
|
|
345
|
+
JSON.stringify({ role: 'user', message: { content: `<user_query>${q}</user_query>` } }) + '\n',
|
|
346
|
+
'utf8');
|
|
347
|
+
|
|
348
|
+
// --- Case A: no .scope.json + prompt q1 -> WRITE scaffold with q1 as intent -
|
|
349
|
+
writeTranscript(q1);
|
|
350
|
+
runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: repoDir, transcript_path: transcriptPath });
|
|
347
351
|
let d = drainedOf(anchorCid);
|
|
348
352
|
if (!existsSync(scopePath)) {
|
|
349
|
-
cleanup(); return { ok: false, detail: '
|
|
353
|
+
cleanup(); return { ok: false, detail: 'scaffold was NOT written on first prompt' };
|
|
350
354
|
}
|
|
351
355
|
let scope;
|
|
352
|
-
try { scope = JSON.parse(readFileSync(scopePath, 'utf8')); }
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
cleanup(); return { ok: false, detail: `scaffold intent mismatch: ${scope.intent}` };
|
|
356
|
+
try { scope = JSON.parse(readFileSync(scopePath, 'utf8')); }
|
|
357
|
+
catch { cleanup(); return { ok: false, detail: '.scope.json is not valid JSON' }; }
|
|
358
|
+
if (scope.intent !== q1) {
|
|
359
|
+
cleanup(); return { ok: false, detail: `intent mismatch (want "${q1}"): ${scope.intent}` };
|
|
357
360
|
}
|
|
358
|
-
if (!d.includes('
|
|
359
|
-
cleanup(); return { ok: false, detail: '
|
|
361
|
+
if (!d.includes('scope regenerated')) {
|
|
362
|
+
cleanup(); return { ok: false, detail: 'regenerated branch did not fire' };
|
|
360
363
|
}
|
|
361
364
|
|
|
362
|
-
// --- Stop clears the latch
|
|
365
|
+
// --- Stop clears the latch -> next turn can act again --------------------
|
|
363
366
|
runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
|
|
364
367
|
|
|
365
|
-
// --- Case B:
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
files: ['src/grid.tsx'],
|
|
369
|
-
acceptance: 'grid renders symmetric; tokens match palette',
|
|
370
|
-
}));
|
|
371
|
-
runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: HOME, transcript_path: transcriptPath });
|
|
368
|
+
// --- Case B: same prompt q1 again -> re-INJECT, do NOT rewrite ----------
|
|
369
|
+
const sizeBefore = statSync(scopePath).size;
|
|
370
|
+
runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: repoDir, transcript_path: transcriptPath });
|
|
372
371
|
d = drainedOf(anchorCid);
|
|
373
|
-
if (!d.includes('
|
|
374
|
-
cleanup(); return { ok: false, detail: '
|
|
372
|
+
if (!d.includes('re-injected this turn')) {
|
|
373
|
+
cleanup(); return { ok: false, detail: 'same-prompt turn did not re-inject' };
|
|
374
|
+
}
|
|
375
|
+
// _intent_hash matches -> file must not have been regenerated. Intent still q1.
|
|
376
|
+
try { scope = JSON.parse(readFileSync(scopePath, 'utf8')); }
|
|
377
|
+
catch { cleanup(); return { ok: false, detail: '.scope.json corrupted on same-prompt turn' }; }
|
|
378
|
+
if (scope.intent !== q1) {
|
|
379
|
+
cleanup(); return { ok: false, detail: `same-prompt turn rewrote intent (should stay "${q1}"): ${scope.intent}` };
|
|
375
380
|
}
|
|
376
381
|
|
|
382
|
+
// --- Stop; new prompt q2 -> REGENERATE with q2 as intent ----------------
|
|
377
383
|
runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
|
|
378
|
-
|
|
384
|
+
writeTranscript(q2);
|
|
385
|
+
runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: repoDir, transcript_path: transcriptPath });
|
|
379
386
|
d = drainedOf(anchorCid);
|
|
380
|
-
|
|
381
|
-
|
|
387
|
+
try { scope = JSON.parse(readFileSync(scopePath, 'utf8')); }
|
|
388
|
+
catch { cleanup(); return { ok: false, detail: '.scope.json corrupted on prompt-change turn' }; }
|
|
389
|
+
if (scope.intent !== q2) {
|
|
390
|
+
cleanup(); return { ok: false, detail: `prompt-change did not regenerate intent (want "${q2}"): ${scope.intent}` };
|
|
391
|
+
}
|
|
392
|
+
if (!d.includes('scope regenerated')) {
|
|
393
|
+
cleanup(); return { ok: false, detail: 'prompt-change turn did not report regeneration' };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// --- The anti-ghost-file guard: no .scope.json should ever exist at $HOME
|
|
397
|
+
// level OUTSIDE the resolved repo. Here repo == HOME so this is moot,
|
|
398
|
+
// but assert the file has the _generated_by marker (proof the hook wrote
|
|
399
|
+
// it, and that it carries _intent_hash for self-contained staleness).
|
|
400
|
+
if (!scope._generated_by || !scope._intent_hash) {
|
|
401
|
+
cleanup(); return { ok: false, detail: 'scaffold missing _generated_by / _intent_hash fields' };
|
|
382
402
|
}
|
|
383
403
|
cleanup();
|
|
384
404
|
return true;
|
|
@@ -15,14 +15,17 @@
|
|
|
15
15
|
# at the START of each turn's work, before edits pile up and dilute the
|
|
16
16
|
# original intent. Works UNCONDITIONALLY - no transcript needed.
|
|
17
17
|
#
|
|
18
|
-
# 2.
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
18
|
+
# 2. AUTO-CREATE / REGENERATE .scope.json: when the current <user_query>
|
|
19
|
+
# differs from the contract on disk (no contract yet, OR _intent_hash
|
|
20
|
+
# mismatch), the hook WRITES a scaffold to the REPO ROOT: intent locked
|
|
21
|
+
# from the prompt, files/acceptance as TODO placeholders the agent
|
|
22
|
+
# refines. This is the user-requested behavior: every new prompt ->
|
|
23
|
+
# a fresh .scope.json the agent works from. Fixed vs the broken 0.4.4
|
|
24
|
+
# build: never writes to $HOME (bails if no real root resolves -> no
|
|
25
|
+
# ghost files), and regenerates on prompt CHANGE not just on absence.
|
|
26
|
+
# 3. RE-INJECT on same-prompt turns: when the query is unchanged (contract
|
|
27
|
+
# already current), the hook re-injects the existing contract into the
|
|
28
|
+
# feedback bus so it stays in the model's attentional focus each turn.
|
|
26
29
|
#
|
|
27
30
|
# Why postToolUse, not afterFileEdit: afterFileEdit only fires AFTER an edit
|
|
28
31
|
# exists, and Cursor has no preToolUse for file edits. postToolUse fires after
|
|
@@ -74,7 +77,10 @@ if [ "$has_query" = "1" ]; then
|
|
|
74
77
|
[ "$current_hash" != "$prev_hash" ] && prompt_changed=1
|
|
75
78
|
fi
|
|
76
79
|
|
|
77
|
-
# --- repo root (same resolution as scope-gate-audit.sh)
|
|
80
|
+
# --- repo root (same resolution as scope-gate-audit.sh, but NO $HOME fallback) -
|
|
81
|
+
# We do NOT fall back to $HOME: writing .scope.json into $HOME was the 0.4.4
|
|
82
|
+
# "ghost file" bug. If we cannot resolve a real project root, the hook stays
|
|
83
|
+
# silent (no scaffold, no demand) rather than litter the user's home dir.
|
|
78
84
|
root=""
|
|
79
85
|
while IFS= read -r cand; do
|
|
80
86
|
[ -n "$cand" ] && [ -d "$cand" ] && { root="${cand%/}"; break; }
|
|
@@ -82,14 +88,18 @@ done <<EOF
|
|
|
82
88
|
$(json_get "$input" cwd)
|
|
83
89
|
$(json_get_array "$input" workspace_roots)
|
|
84
90
|
EOF
|
|
85
|
-
[ -
|
|
86
|
-
root="${
|
|
91
|
+
if [ -z "$root" ] && [ -n "$CURSOR_PROJECT_DIR" ] && [ -d "$CURSOR_PROJECT_DIR" ]; then
|
|
92
|
+
root="${CURSOR_PROJECT_DIR%/}"
|
|
93
|
+
fi
|
|
94
|
+
# No $HOME fallback. If we still have no root, bail (cannot know where to write).
|
|
95
|
+
[ -n "$root" ] || exit 0
|
|
87
96
|
|
|
88
97
|
# --- read the existing contract (if any) -------------------------------------
|
|
89
98
|
scope_exists=0
|
|
90
99
|
scope_intent=""
|
|
91
100
|
scope_acceptance=""
|
|
92
101
|
scope_files=""
|
|
102
|
+
scope_stale=0 # 1 if on-disk contract predates the current query
|
|
93
103
|
scope_path="$root/.scope.json"
|
|
94
104
|
if [ -f "$scope_path" ]; then
|
|
95
105
|
# Prefer jq; fall back to python3 (mirrors hook-common.sh degrade policy).
|
|
@@ -97,9 +107,10 @@ if [ -f "$scope_path" ]; then
|
|
|
97
107
|
scope_intent="$(jq -r '.intent // empty' "$scope_path" 2>/dev/null)"
|
|
98
108
|
scope_acceptance="$(jq -r '.acceptance // empty' "$scope_path" 2>/dev/null)"
|
|
99
109
|
scope_files="$(jq -r '(.files // []) | join(", ")' "$scope_path" 2>/dev/null)"
|
|
110
|
+
on_disk_hash="$(jq -r '._intent_hash // empty' "$scope_path" 2>/dev/null)"
|
|
100
111
|
scope_exists=1
|
|
101
112
|
elif have_py; then
|
|
102
|
-
read -r scope_intent scope_acceptance scope_files <<EOF
|
|
113
|
+
read -r scope_intent scope_acceptance scope_files on_disk_hash <<EOF
|
|
103
114
|
$(python3 -c '
|
|
104
115
|
import json, sys
|
|
105
116
|
try:
|
|
@@ -107,91 +118,100 @@ try:
|
|
|
107
118
|
print(d.get("intent","") or "")
|
|
108
119
|
print(d.get("acceptance","") or "")
|
|
109
120
|
print(", ".join(d.get("files",[]) or []))
|
|
121
|
+
print(d.get("_intent_hash","") or "")
|
|
110
122
|
except Exception:
|
|
111
123
|
sys.exit(1)
|
|
112
124
|
' "$scope_path" 2>/dev/null)
|
|
113
125
|
EOF
|
|
114
126
|
[ $? -eq 0 ] && scope_exists=1 || scope_exists=0
|
|
115
127
|
fi
|
|
128
|
+
# Stale if we have a query AND the on-disk _intent_hash differs from it.
|
|
129
|
+
if [ "$scope_exists" = "1" ] && [ "$has_query" = "1" ] && [ -n "$on_disk_hash" ]; then
|
|
130
|
+
[ "$on_disk_hash" != "$current_hash" ] && scope_stale=1
|
|
131
|
+
fi
|
|
116
132
|
fi
|
|
117
133
|
|
|
118
|
-
# ---
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
134
|
+
# --- auto-create / regenerate .scope.json (the 0.4.4 behavior, fixed) --------
|
|
135
|
+
# The user wants: every NEW prompt -> a fresh .scope.json the agent works from.
|
|
136
|
+
# So we WRITE the scaffold when (a) there is no valid contract, OR (b) the
|
|
137
|
+
# contract on disk is stale (its _intent_hash != current query hash). Fixed vs
|
|
138
|
+
# 0.4.4: never writes to $HOME (bail above if no real root) -> no ghost files;
|
|
139
|
+
# regenerates on prompt CHANGE not just absence -> "each prompt, new file".
|
|
140
|
+
regenerated=0
|
|
141
|
+
should_write=0
|
|
142
|
+
if [ "$has_query" = "1" ]; then
|
|
143
|
+
if [ "$scope_exists" != "1" ] || [ "$scope_stale" = "1" ]; then
|
|
144
|
+
should_write=1
|
|
145
|
+
fi
|
|
146
|
+
fi
|
|
124
147
|
|
|
125
|
-
if [ "$
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
148
|
+
if [ "$should_write" = "1" ]; then
|
|
149
|
+
# jq preferred; python3 fallback. Write intent from the query, TODO
|
|
150
|
+
# placeholders for files/acceptance, and record _intent_hash so staleness
|
|
151
|
+
# is self-contained in the file (survives cross-session hash sweeps).
|
|
152
|
+
if have_jq; then
|
|
153
|
+
jq -n --arg intent "$current_query" --arg hash "$current_hash" \
|
|
154
|
+
'{intent:$intent, files:["<TODO: list files you will touch>"], acceptance:"<TODO: the one deterministic check that decides done>", allow_growth:false, _intent_hash:$hash, _generated_by:"intent-anchor hook"}' \
|
|
155
|
+
> "$scope_path" 2>/dev/null && regenerated=1
|
|
156
|
+
elif have_py; then
|
|
157
|
+
if I_FILE="$scope_path" I_INTENT="$current_query" I_HASH="$current_hash" python3 -c '
|
|
158
|
+
import json, os
|
|
130
159
|
obj = {
|
|
131
|
-
"intent":
|
|
132
|
-
"files": ["<TODO: list files>"],
|
|
133
|
-
"acceptance": "<TODO: deterministic
|
|
160
|
+
"intent": os.environ["I_INTENT"],
|
|
161
|
+
"files": ["<TODO: list files you will touch>"],
|
|
162
|
+
"acceptance": "<TODO: the one deterministic check that decides done>",
|
|
134
163
|
"allow_growth": False,
|
|
164
|
+
"_intent_hash": os.environ["I_HASH"],
|
|
165
|
+
"_generated_by": "intent-anchor hook",
|
|
135
166
|
}
|
|
136
|
-
with open(
|
|
137
|
-
json.dump(obj, f, ensure_ascii=False)
|
|
138
|
-
'
|
|
139
|
-
|
|
140
|
-
scope_exists=1
|
|
141
|
-
scope_intent="$current_query"
|
|
142
|
-
scope_acceptance="<TODO: deterministic success check>"
|
|
143
|
-
scope_files="<TODO: list files>"
|
|
167
|
+
with open(os.environ["I_FILE"], "w", encoding="utf-8") as f:
|
|
168
|
+
json.dump(obj, f, ensure_ascii=False, indent=2)
|
|
169
|
+
' 2>/dev/null; then
|
|
170
|
+
regenerated=1
|
|
144
171
|
fi
|
|
145
172
|
fi
|
|
173
|
+
if [ "$regenerated" = "1" ]; then
|
|
174
|
+
scope_intent="$current_query"
|
|
175
|
+
scope_acceptance="<TODO: the one deterministic check that decides done>"
|
|
176
|
+
scope_files="<TODO: list files you will touch>"
|
|
177
|
+
scope_exists=1
|
|
178
|
+
scope_stale=0
|
|
179
|
+
fi
|
|
146
180
|
fi
|
|
147
181
|
|
|
148
182
|
# --- compose the anchor message ---------------------------------------------
|
|
183
|
+
# Three states: regenerated this turn (new prompt), no contract (and no query
|
|
184
|
+
# to scaffold from), or re-injecting an existing current contract.
|
|
149
185
|
if [ "$has_query" = "1" ]; then
|
|
150
186
|
query_line="$current_query"
|
|
151
187
|
else
|
|
152
188
|
query_line="(current request unavailable - no transcript in this event)"
|
|
153
189
|
fi
|
|
154
190
|
|
|
155
|
-
if [ "$
|
|
156
|
-
msg="INTENT ANCHOR (
|
|
191
|
+
if [ "$regenerated" = "1" ]; then
|
|
192
|
+
msg="INTENT ANCHOR (scope regenerated) - .scope.json written for this prompt.
|
|
157
193
|
|
|
158
194
|
intent: $scope_intent
|
|
159
195
|
files: $scope_files
|
|
160
196
|
acceptance: $scope_acceptance
|
|
161
197
|
|
|
162
|
-
The hook wrote
|
|
163
|
-
|
|
164
|
-
|
|
198
|
+
The hook wrote a fresh scaffold to $scope_path from your current request. intent
|
|
199
|
+
is locked from what you just asked. Fill the TODO placeholders with the real
|
|
200
|
+
files you will touch and the deterministic acceptance check, THEN proceed. This
|
|
201
|
+
contract will be re-injected every turn until your request changes again."
|
|
165
202
|
elif [ "$scope_exists" != "1" ]; then
|
|
166
|
-
msg="INTENT ANCHOR (pre-compile) - no .scope.json found in $root
|
|
203
|
+
msg="INTENT ANCHOR (pre-compile) - no .scope.json found in $root, and the current
|
|
204
|
+
request was unavailable to scaffold from.
|
|
167
205
|
|
|
168
206
|
Current request:
|
|
169
207
|
$query_line
|
|
170
208
|
|
|
171
|
-
|
|
172
|
-
in the repo root:
|
|
209
|
+
Write .scope.json in the repo root yourself:
|
|
173
210
|
intent: one operational sentence (what is strictly necessary)
|
|
174
211
|
files: the exact files you will touch
|
|
175
|
-
acceptance: the one deterministic check that decides done
|
|
176
|
-
|
|
177
|
-
Compile it now, then proceed. The scope tracks the request - it is how you stay
|
|
178
|
-
on the rails when the conversation gets long."
|
|
179
|
-
elif [ "$prompt_changed" = "1" ]; then
|
|
180
|
-
msg="INTENT ANCHOR (pre-compile) - your request changed; .scope.json may be stale.
|
|
181
|
-
|
|
182
|
-
Current request:
|
|
183
|
-
$query_line
|
|
184
|
-
|
|
185
|
-
Your existing contract (.scope.json):
|
|
186
|
-
intent: $scope_intent
|
|
187
|
-
files: $scope_files
|
|
188
|
-
acceptance: $scope_acceptance
|
|
189
|
-
|
|
190
|
-
If the current request differs from the intent above, UPDATE .scope.json now
|
|
191
|
-
to match what was just asked. When the request moves, the scope moves with it -
|
|
192
|
-
do not edit against a contract written for a different request."
|
|
212
|
+
acceptance: the one deterministic check that decides done"
|
|
193
213
|
else
|
|
194
|
-
#
|
|
214
|
+
# Contract exists and matches the current prompt -> re-inject it.
|
|
195
215
|
if [ "$has_query" = "1" ]; then
|
|
196
216
|
drift_note="Every edit this turn must advance intent and stay inside files. acceptance is the bar for done."
|
|
197
217
|
else
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursordoctrine",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "Thin self-review hooks for Cursor — the model is the auditor. Proactive intent compilation (pre-compile Anchor Set +
|
|
3
|
+
"version": "0.4.6",
|
|
4
|
+
"description": "Thin self-review hooks for Cursor — the model is the auditor. Proactive intent compilation (pre-compile Anchor Set + auto-scaffolded .scope.json that regenerates per prompt and re-injects every turn 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"
|
|
7
7
|
},
|
|
@@ -14,14 +14,17 @@
|
|
|
14
14
|
# at the START of each turn's work, before edits pile up and dilute the
|
|
15
15
|
# original intent. Works UNCONDITIONALLY - no transcript needed.
|
|
16
16
|
#
|
|
17
|
-
# 2.
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
17
|
+
# 2. AUTO-CREATE / REGENERATE .scope.json: when the current <user_query>
|
|
18
|
+
# differs from the contract on disk (no contract yet, OR _intent_hash
|
|
19
|
+
# mismatch), the hook WRITES a scaffold to the REPO ROOT: intent locked
|
|
20
|
+
# from the prompt, files/acceptance as TODO placeholders the agent
|
|
21
|
+
# refines. This is the user-requested behavior: every new prompt ->
|
|
22
|
+
# a fresh .scope.json the agent works from. Fixed vs the broken 0.4.4
|
|
23
|
+
# build: never writes to $HOME (bails if no real root resolves -> no
|
|
24
|
+
# ghost files), and regenerates on prompt CHANGE not just on absence.
|
|
25
|
+
# 3. RE-INJECT on same-prompt turns: when the query is unchanged (contract
|
|
26
|
+
# already current), the hook re-injects the existing contract into the
|
|
27
|
+
# feedback bus so it stays in the model's attentional focus each turn.
|
|
25
28
|
#
|
|
26
29
|
# Why postToolUse, not afterFileEdit: afterFileEdit only fires AFTER an edit
|
|
27
30
|
# exists, and Cursor has no preToolUse for file edits. postToolUse fires after
|
|
@@ -70,18 +73,28 @@ if ($hasQuery) {
|
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
# --- repo root (same resolution as scope-gate-audit.ps1) ---------------------
|
|
76
|
+
# Resolve cwd -> workspace_roots -> CURSOR_PROJECT_DIR. We do NOT fall back to
|
|
77
|
+
# $HOME: writing .scope.json into $HOME was the 0.4.4 "ghost file" bug. If we
|
|
78
|
+
# cannot resolve a real project root, the hook stays silent (no scaffold, no
|
|
79
|
+
# demand) rather than litter the user's home directory.
|
|
73
80
|
$root = ''
|
|
74
81
|
$cands = @()
|
|
75
82
|
if ($obj.PSObject.Properties['cwd'] -and $obj.cwd) { $cands += [string]$obj.cwd }
|
|
76
83
|
if ($obj.PSObject.Properties['workspace_roots']) { foreach ($w in $obj.workspace_roots) { $cands += [string]$w } }
|
|
77
84
|
foreach ($c in $cands) { $f = ConvertTo-FwdPath $c; if ($f -and (Test-Path -LiteralPath $f)) { $root = $f.TrimEnd('/'); break } }
|
|
78
|
-
if (-not $root
|
|
85
|
+
if (-not $root -and $env:CURSOR_PROJECT_DIR) {
|
|
86
|
+
$cpd = $env:CURSOR_PROJECT_DIR.Replace('\', '/').TrimEnd('/')
|
|
87
|
+
if (Test-Path -LiteralPath $cpd) { $root = $cpd }
|
|
88
|
+
}
|
|
89
|
+
# No $HOME fallback. If we still have no root, bail (cannot know where to write).
|
|
90
|
+
if (-not $root) { exit 0 }
|
|
79
91
|
|
|
80
92
|
# --- read the existing contract (if any) -------------------------------------
|
|
81
93
|
$scopeExists = $false
|
|
82
94
|
$scopeIntent = ''
|
|
83
95
|
$scopeAcceptance = ''
|
|
84
96
|
$scopeFiles = ''
|
|
97
|
+
$scopeStale = $false # true if the on-disk contract predates the current query
|
|
85
98
|
$scopePath = Join-Path $root '.scope.json'
|
|
86
99
|
if (Test-Path -LiteralPath $scopePath) {
|
|
87
100
|
try {
|
|
@@ -90,89 +103,80 @@ if (Test-Path -LiteralPath $scopePath) {
|
|
|
90
103
|
if ($sj.acceptance) { $scopeAcceptance = [string]$sj.acceptance }
|
|
91
104
|
if ($sj.files) { $scopeFiles = ($sj.files -join ', ') }
|
|
92
105
|
$scopeExists = $true
|
|
106
|
+
# The contract is "stale" if its recorded intent hash != current query
|
|
107
|
+
# hash. We persist the query hash inside .scope.json under _intent_hash
|
|
108
|
+
# so staleness survives even if last-query-<cid>.hash was swept.
|
|
109
|
+
if ($hasQuery -and $sj.PSObject.Properties['_intent_hash']) {
|
|
110
|
+
$scopeStale = ([string]$sj._intent_hash -ne $currentHash)
|
|
111
|
+
}
|
|
93
112
|
} catch { $scopeExists = $false } # malformed JSON -> treat as missing
|
|
94
113
|
}
|
|
95
114
|
|
|
96
|
-
# ---
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
115
|
+
# --- auto-create / regenerate .scope.json (the 0.4.4 behavior, fixed) --------
|
|
116
|
+
# The user wants: every NEW prompt -> a fresh .scope.json the agent works from.
|
|
117
|
+
# So we WRITE the scaffold when (a) there is no valid contract, OR (b) the
|
|
118
|
+
# contract on disk is stale (its _intent_hash != current query hash). Intent is
|
|
119
|
+
# locked from the current <user_query>; files/acceptance are TODO placeholders
|
|
120
|
+
# the agent refines. Fixed vs 0.4.4:
|
|
121
|
+
# - NEVER writes to $HOME (bail above if no real root) -> no ghost files.
|
|
122
|
+
# - Regenerates on prompt CHANGE, not just on absence -> "each prompt, new file".
|
|
123
|
+
# - Records _intent_hash so staleness is self-contained in the file.
|
|
124
|
+
$regenerated = $false
|
|
125
|
+
$shouldWrite = $hasQuery -and (-not $scopeExists -or $scopeStale)
|
|
126
|
+
if ($shouldWrite) {
|
|
104
127
|
try {
|
|
105
128
|
$scaffold = [ordered]@{
|
|
106
|
-
intent
|
|
107
|
-
files
|
|
108
|
-
acceptance
|
|
109
|
-
allow_growth
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
129
|
+
intent = $currentQuery
|
|
130
|
+
files = @('<TODO: list files you will touch>')
|
|
131
|
+
acceptance = '<TODO: the one deterministic check that decides done>'
|
|
132
|
+
allow_growth = $false
|
|
133
|
+
_intent_hash = $currentHash
|
|
134
|
+
_generated_by = 'intent-anchor hook'
|
|
115
135
|
}
|
|
136
|
+
$json = $scaffold | ConvertTo-Json -Depth 5
|
|
116
137
|
[System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
|
|
117
|
-
$scopeIntent
|
|
118
|
-
$scopeAcceptance = '<TODO: deterministic
|
|
119
|
-
$scopeFiles
|
|
120
|
-
$scopeExists
|
|
121
|
-
$
|
|
122
|
-
|
|
138
|
+
$scopeIntent = $currentQuery
|
|
139
|
+
$scopeAcceptance = '<TODO: the one deterministic check that decides done>'
|
|
140
|
+
$scopeFiles = '<TODO: list files you will touch>'
|
|
141
|
+
$scopeExists = $true
|
|
142
|
+
$scopeStale = $false
|
|
143
|
+
$regenerated = $true
|
|
144
|
+
} catch { } # write failed (perms / locked) -> fall through to demand msg
|
|
123
145
|
}
|
|
124
146
|
|
|
125
147
|
# --- compose the anchor message ---------------------------------------------
|
|
126
|
-
#
|
|
127
|
-
#
|
|
148
|
+
# Three states: regenerated this turn (new prompt), no contract (and no query
|
|
149
|
+
# to scaffold from), or re-injecting an existing current contract.
|
|
128
150
|
$queryLine = if ($hasQuery) { $currentQuery } else { '(current request unavailable - no transcript in this event)' }
|
|
129
151
|
|
|
130
|
-
if ($
|
|
152
|
+
if ($regenerated) {
|
|
131
153
|
$msg = @"
|
|
132
|
-
INTENT ANCHOR (
|
|
154
|
+
INTENT ANCHOR (scope regenerated) - .scope.json written for this prompt.
|
|
133
155
|
|
|
134
156
|
intent: $scopeIntent
|
|
135
157
|
files: $scopeFiles
|
|
136
158
|
acceptance: $scopeAcceptance
|
|
137
159
|
|
|
138
|
-
The hook wrote
|
|
139
|
-
|
|
140
|
-
|
|
160
|
+
The hook wrote a fresh scaffold to $scopePath from your current request. intent
|
|
161
|
+
is locked from what you just asked. Fill the TODO placeholders with the real
|
|
162
|
+
files you will touch and the deterministic acceptance check, THEN proceed. This
|
|
163
|
+
contract will be re-injected every turn until your request changes again.
|
|
141
164
|
"@
|
|
142
165
|
} elseif (-not $scopeExists) {
|
|
143
166
|
$msg = @"
|
|
144
|
-
INTENT ANCHOR (pre-compile) - no .scope.json found in $root
|
|
167
|
+
INTENT ANCHOR (pre-compile) - no .scope.json found in $root, and the current
|
|
168
|
+
request was unavailable to scaffold from.
|
|
145
169
|
|
|
146
170
|
Current request:
|
|
147
171
|
$queryLine
|
|
148
172
|
|
|
149
|
-
|
|
150
|
-
in the repo root:
|
|
173
|
+
Write .scope.json in the repo root yourself:
|
|
151
174
|
intent: one operational sentence (what is strictly necessary)
|
|
152
175
|
files: the exact files you will touch
|
|
153
176
|
acceptance: the one deterministic check that decides done
|
|
154
|
-
|
|
155
|
-
Compile it now, then proceed. The scope tracks the request - it is how you stay
|
|
156
|
-
on the rails when the conversation gets long.
|
|
157
|
-
"@
|
|
158
|
-
} elseif ($promptChanged) {
|
|
159
|
-
$msg = @"
|
|
160
|
-
INTENT ANCHOR (pre-compile) - your request changed; .scope.json may be stale.
|
|
161
|
-
|
|
162
|
-
Current request:
|
|
163
|
-
$queryLine
|
|
164
|
-
|
|
165
|
-
Your existing contract (.scope.json):
|
|
166
|
-
intent: $scopeIntent
|
|
167
|
-
files: $scopeFiles
|
|
168
|
-
acceptance: $scopeAcceptance
|
|
169
|
-
|
|
170
|
-
If the current request differs from the intent above, UPDATE .scope.json now
|
|
171
|
-
to match what was just asked. When the request moves, the scope moves with it -
|
|
172
|
-
do not edit against a contract written for a different request.
|
|
173
177
|
"@
|
|
174
178
|
} else {
|
|
175
|
-
#
|
|
179
|
+
# Contract exists and matches the current prompt -> re-inject it.
|
|
176
180
|
$driftNote = if ($hasQuery) {
|
|
177
181
|
"Every edit this turn must advance intent and stay inside files. acceptance is the bar for done."
|
|
178
182
|
} else {
|