cursordoctrine 0.4.3 → 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 +4 -2
- package/bin/cli.mjs +86 -26
- package/linux/hooks/intent-anchor.sh +95 -35
- package/package.json +2 -2
- package/windows/hooks/intent-anchor.ps1 +79 -34
package/README.md
CHANGED
|
@@ -100,8 +100,10 @@ Writing `.scope.json` once is not enough. As a conversation fills with code, log
|
|
|
100
100
|
|
|
101
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. **
|
|
104
|
-
2. **Re-
|
|
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.
|
|
105
107
|
|
|
106
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.
|
|
107
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';
|
|
@@ -74,6 +75,26 @@ function mergeHooks(existing, incoming, keys) {
|
|
|
74
75
|
if (i >= 0) cur[i] = entry;
|
|
75
76
|
else cur.push(entry);
|
|
76
77
|
}
|
|
78
|
+
// Re-order our entries to match the shipped hooks.json (merge used to leave
|
|
79
|
+
// stale order — e.g. post-tool-use before intent-anchor — breaking same-tool
|
|
80
|
+
// delivery of the anchor message).
|
|
81
|
+
const foreign = cur.filter((x) => x && !isOurs(x.command, keys));
|
|
82
|
+
const reordered = [];
|
|
83
|
+
const used = new Set();
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
const k = keyOf(entry.command, keys);
|
|
86
|
+
if (!k || !isOurs(entry.command, keys)) continue;
|
|
87
|
+
const found = cur.find((x) => x && keyOf(x.command, keys) === k);
|
|
88
|
+
if (found) {
|
|
89
|
+
reordered.push(found);
|
|
90
|
+
used.add(k);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const x of cur) {
|
|
94
|
+
const k = keyOf(x?.command, keys);
|
|
95
|
+
if (isOurs(x?.command, keys) && k && !used.has(k)) reordered.push(x);
|
|
96
|
+
}
|
|
97
|
+
out.hooks[event] = [...reordered, ...foreign];
|
|
77
98
|
}
|
|
78
99
|
let preserved = 0;
|
|
79
100
|
for (const entries of Object.values(out.hooks)) {
|
|
@@ -300,45 +321,84 @@ function verify() {
|
|
|
300
321
|
return true;
|
|
301
322
|
});
|
|
302
323
|
|
|
303
|
-
check('intent-anchor
|
|
304
|
-
//
|
|
305
|
-
//
|
|
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).
|
|
306
330
|
const drainedOf = (cidv) => runHook(hook('post-tool-use'), { conversation_id: cidv });
|
|
307
331
|
const anchorCid = 'npxv4';
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
const
|
|
332
|
+
const repoDir = HOME; // verify() runs under a pinned HOME; treat it as the repo
|
|
333
|
+
const scopePath = join(repoDir, '.scope.json');
|
|
334
|
+
const transcriptPath = join(HOME, '.cursor', '.hooks-pending', 'verify-transcript-npxv4.jsonl');
|
|
335
|
+
const q1 = 'fix grid symmetry and color tokens';
|
|
336
|
+
const q2 = 'now add dark mode support';
|
|
337
|
+
|
|
338
|
+
const cleanup = () => {
|
|
339
|
+
try { rmSync(scopePath, { force: true }); } catch {}
|
|
340
|
+
try { rmSync(transcriptPath, { force: true }); } catch {}
|
|
341
|
+
};
|
|
311
342
|
cleanup();
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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 });
|
|
315
351
|
let d = drainedOf(anchorCid);
|
|
316
|
-
if (!
|
|
317
|
-
cleanup(); return { ok: false, detail: '
|
|
352
|
+
if (!existsSync(scopePath)) {
|
|
353
|
+
cleanup(); return { ok: false, detail: 'scaffold was NOT written on first prompt' };
|
|
354
|
+
}
|
|
355
|
+
let scope;
|
|
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}` };
|
|
360
|
+
}
|
|
361
|
+
if (!d.includes('scope regenerated')) {
|
|
362
|
+
cleanup(); return { ok: false, detail: 'regenerated branch did not fire' };
|
|
318
363
|
}
|
|
319
364
|
|
|
320
|
-
// --- Stop clears the latch
|
|
365
|
+
// --- Stop clears the latch -> next turn can act again --------------------
|
|
321
366
|
runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
|
|
322
367
|
|
|
323
|
-
// --- Case B:
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
files: ['src/grid.tsx'],
|
|
327
|
-
acceptance: 'grid renders symmetric; tokens match palette',
|
|
328
|
-
}));
|
|
329
|
-
// Turn 2 (no transcript in sandbox -> query unavailable -> re-inject branch).
|
|
330
|
-
runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: HOME });
|
|
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 });
|
|
331
371
|
d = drainedOf(anchorCid);
|
|
332
|
-
if (!d.includes('
|
|
333
|
-
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}` };
|
|
334
380
|
}
|
|
335
381
|
|
|
336
|
-
// --- Stop
|
|
382
|
+
// --- Stop; new prompt q2 -> REGENERATE with q2 as intent ----------------
|
|
337
383
|
runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
|
|
338
|
-
|
|
384
|
+
writeTranscript(q2);
|
|
385
|
+
runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: repoDir, transcript_path: transcriptPath });
|
|
339
386
|
d = drainedOf(anchorCid);
|
|
340
|
-
|
|
341
|
-
|
|
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' };
|
|
342
402
|
}
|
|
343
403
|
cleanup();
|
|
344
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,51 +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
|
|
132
|
+
fi
|
|
133
|
+
|
|
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
|
|
147
|
+
|
|
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
|
|
159
|
+
obj = {
|
|
160
|
+
"intent": os.environ["I_INTENT"],
|
|
161
|
+
"files": ["<TODO: list files you will touch>"],
|
|
162
|
+
"acceptance": "<TODO: the one deterministic check that decides done>",
|
|
163
|
+
"allow_growth": False,
|
|
164
|
+
"_intent_hash": os.environ["I_HASH"],
|
|
165
|
+
"_generated_by": "intent-anchor hook",
|
|
166
|
+
}
|
|
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
|
|
171
|
+
fi
|
|
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
|
|
116
180
|
fi
|
|
117
181
|
|
|
118
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.
|
|
119
185
|
if [ "$has_query" = "1" ]; then
|
|
120
186
|
query_line="$current_query"
|
|
121
187
|
else
|
|
122
188
|
query_line="(current request unavailable - no transcript in this event)"
|
|
123
189
|
fi
|
|
124
190
|
|
|
125
|
-
if [ "$
|
|
126
|
-
msg="INTENT ANCHOR (
|
|
191
|
+
if [ "$regenerated" = "1" ]; then
|
|
192
|
+
msg="INTENT ANCHOR (scope regenerated) - .scope.json written for this prompt.
|
|
127
193
|
|
|
128
|
-
|
|
129
|
-
$
|
|
130
|
-
|
|
131
|
-
You have NOT compiled your Anchor Set. Before editing files, write .scope.json
|
|
132
|
-
in the repo root:
|
|
133
|
-
intent: one operational sentence (what is strictly necessary)
|
|
134
|
-
files: the exact files you will touch
|
|
135
|
-
acceptance: the one deterministic check that decides done
|
|
194
|
+
intent: $scope_intent
|
|
195
|
+
files: $scope_files
|
|
196
|
+
acceptance: $scope_acceptance
|
|
136
197
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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."
|
|
202
|
+
elif [ "$scope_exists" != "1" ]; then
|
|
203
|
+
msg="INTENT ANCHOR (pre-compile) - no .scope.json found in $root, and the current
|
|
204
|
+
request was unavailable to scaffold from.
|
|
141
205
|
|
|
142
206
|
Current request:
|
|
143
207
|
$query_line
|
|
144
208
|
|
|
145
|
-
|
|
146
|
-
intent:
|
|
147
|
-
files:
|
|
148
|
-
acceptance:
|
|
149
|
-
|
|
150
|
-
If the current request differs from the intent above, UPDATE .scope.json now
|
|
151
|
-
to match what was just asked. When the request moves, the scope moves with it -
|
|
152
|
-
do not edit against a contract written for a different request."
|
|
209
|
+
Write .scope.json in the repo root yourself:
|
|
210
|
+
intent: one operational sentence (what is strictly necessary)
|
|
211
|
+
files: the exact files you will touch
|
|
212
|
+
acceptance: the one deterministic check that decides done"
|
|
153
213
|
else
|
|
154
|
-
#
|
|
214
|
+
# Contract exists and matches the current prompt -> re-inject it.
|
|
155
215
|
if [ "$has_query" = "1" ]; then
|
|
156
216
|
drift_note="Every edit this turn must advance intent and stay inside files. acceptance is the bar for done."
|
|
157
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,48 +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
|
|
|
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) {
|
|
127
|
+
try {
|
|
128
|
+
$scaffold = [ordered]@{
|
|
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'
|
|
135
|
+
}
|
|
136
|
+
$json = $scaffold | ConvertTo-Json -Depth 5
|
|
137
|
+
[System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
|
|
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
|
|
145
|
+
}
|
|
146
|
+
|
|
96
147
|
# --- compose the anchor message ---------------------------------------------
|
|
97
|
-
#
|
|
98
|
-
#
|
|
148
|
+
# Three states: regenerated this turn (new prompt), no contract (and no query
|
|
149
|
+
# to scaffold from), or re-injecting an existing current contract.
|
|
99
150
|
$queryLine = if ($hasQuery) { $currentQuery } else { '(current request unavailable - no transcript in this event)' }
|
|
100
151
|
|
|
101
|
-
if (
|
|
152
|
+
if ($regenerated) {
|
|
102
153
|
$msg = @"
|
|
103
|
-
INTENT ANCHOR (
|
|
104
|
-
|
|
105
|
-
Current request:
|
|
106
|
-
$queryLine
|
|
154
|
+
INTENT ANCHOR (scope regenerated) - .scope.json written for this prompt.
|
|
107
155
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
files: the exact files you will touch
|
|
112
|
-
acceptance: the one deterministic check that decides done
|
|
156
|
+
intent: $scopeIntent
|
|
157
|
+
files: $scopeFiles
|
|
158
|
+
acceptance: $scopeAcceptance
|
|
113
159
|
|
|
114
|
-
|
|
115
|
-
|
|
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.
|
|
116
164
|
"@
|
|
117
|
-
} elseif ($
|
|
165
|
+
} elseif (-not $scopeExists) {
|
|
118
166
|
$msg = @"
|
|
119
|
-
INTENT ANCHOR (pre-compile) -
|
|
167
|
+
INTENT ANCHOR (pre-compile) - no .scope.json found in $root, and the current
|
|
168
|
+
request was unavailable to scaffold from.
|
|
120
169
|
|
|
121
170
|
Current request:
|
|
122
171
|
$queryLine
|
|
123
172
|
|
|
124
|
-
|
|
125
|
-
intent:
|
|
126
|
-
files:
|
|
127
|
-
acceptance:
|
|
128
|
-
|
|
129
|
-
If the current request differs from the intent above, UPDATE .scope.json now
|
|
130
|
-
to match what was just asked. When the request moves, the scope moves with it -
|
|
131
|
-
do not edit against a contract written for a different request.
|
|
173
|
+
Write .scope.json in the repo root yourself:
|
|
174
|
+
intent: one operational sentence (what is strictly necessary)
|
|
175
|
+
files: the exact files you will touch
|
|
176
|
+
acceptance: the one deterministic check that decides done
|
|
132
177
|
"@
|
|
133
178
|
} else {
|
|
134
|
-
#
|
|
179
|
+
# Contract exists and matches the current prompt -> re-inject it.
|
|
135
180
|
$driftNote = if ($hasQuery) {
|
|
136
181
|
"Every edit this turn must advance intent and stay inside files. acceptance is the bar for done."
|
|
137
182
|
} else {
|