cursordoctrine 0.5.4 → 0.6.0
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/bin/cli.mjs +69 -0
- package/linux/hooks/final-review.md +13 -0
- package/linux/hooks/final-review.sh +1 -1
- package/linux/hooks/intent-anchor.sh +90 -23
- package/linux/pre-compile.md +29 -17
- package/package.json +1 -1
- package/skills/anti-slop/scripts/scope_match.py +6 -2
- package/windows/hooks/final-review.md +13 -0
- package/windows/hooks/final-review.ps1 +1 -1
- package/windows/hooks/intent-anchor.ps1 +85 -31
- package/windows/pre-compile.md +29 -17
package/bin/cli.mjs
CHANGED
|
@@ -334,6 +334,25 @@ function verify() {
|
|
|
334
334
|
JSON.stringify({ role: 'user', message: { content: `<user_query>${q}</user_query>` } }) + '\n',
|
|
335
335
|
'utf8');
|
|
336
336
|
|
|
337
|
+
// --- Case 0: no .scope.json + NO transcript -> WRITE scaffold anyway -------
|
|
338
|
+
// This is the 0.5.3 regression: creation was gated on $hasQuery, so when
|
|
339
|
+
// Cursor didn't surface transcript_path in the first postToolUse fire, the
|
|
340
|
+
// scope never appeared. Now creation is unconditional on a real root.
|
|
341
|
+
runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: repoDir });
|
|
342
|
+
let d0 = drainedOf(anchorCid);
|
|
343
|
+
if (!existsSync(scopePath)) {
|
|
344
|
+
cleanup(); return { ok: false, detail: 'scaffold NOT created without transcript (0.5.3 regression)' };
|
|
345
|
+
}
|
|
346
|
+
let scope0;
|
|
347
|
+
try { scope0 = JSON.parse(readFileSync(scopePath, 'utf8')); }
|
|
348
|
+
catch { cleanup(); return { ok: false, detail: '.scope.json (no-transcript) is not valid JSON' }; }
|
|
349
|
+
if (!scope0.intent || !scope0.intent.includes('TODO')) {
|
|
350
|
+
cleanup(); return { ok: false, detail: `no-transcript scaffold should have intent <TODO>, got: ${scope0.intent}` };
|
|
351
|
+
}
|
|
352
|
+
// Clear the latch + scope so Case A starts fresh (with a real query this time).
|
|
353
|
+
runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
|
|
354
|
+
cleanup();
|
|
355
|
+
|
|
337
356
|
// --- Case A: no .scope.json + prompt q1 -> WRITE scaffold with q1 as intent -
|
|
338
357
|
writeTranscript(q1);
|
|
339
358
|
runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: repoDir, transcript_path: transcriptPath });
|
|
@@ -389,6 +408,56 @@ function verify() {
|
|
|
389
408
|
if (!scope._generated_by || !scope._intent_hash) {
|
|
390
409
|
cleanup(); return { ok: false, detail: 'scaffold missing _generated_by / _intent_hash fields' };
|
|
391
410
|
}
|
|
411
|
+
|
|
412
|
+
// --- Case C: MODEL-written scope (no _intent_hash) + a NEW prompt --------
|
|
413
|
+
// Reproduces the shipped bug (chiquipuesto/WAVE): the model wrote .scope.json
|
|
414
|
+
// per the legacy 4-field schema, so the hook could never detect staleness and
|
|
415
|
+
// scope-gate kept appending files across features. A new prompt (fresh cid =>
|
|
416
|
+
// promptChanged) must now REGENERATE and RESET files[].
|
|
417
|
+
const mkT = (p, q) => writeFileSync(p,
|
|
418
|
+
JSON.stringify({ role: 'user', message: { content: `<user_query>${q}</user_query>` } }) + '\n', 'utf8');
|
|
419
|
+
const cidC = 'npxv5';
|
|
420
|
+
const transcriptC = join(HOME, '.cursor', '.hooks-pending', 'verify-transcript-npxv5.jsonl');
|
|
421
|
+
const failC = (detail) => { try { rmSync(transcriptC, { force: true }); } catch {} cleanup(); return { ok: false, detail }; };
|
|
422
|
+
try { rmSync(scopePath, { force: true }); } catch {}
|
|
423
|
+
writeFileSync(scopePath, JSON.stringify({ intent: q1, files: ['a.ts', 'b.ts'], acceptance: 'keep', allow_growth: false }, null, 2), 'utf8');
|
|
424
|
+
mkT(transcriptC, q2);
|
|
425
|
+
runHook(hook('intent-anchor'), { conversation_id: cidC, cwd: repoDir, transcript_path: transcriptC });
|
|
426
|
+
drainedOf(cidC);
|
|
427
|
+
let sc;
|
|
428
|
+
try { sc = JSON.parse(readFileSync(scopePath, 'utf8')); } catch { return failC('model-written scope corrupted after new prompt'); }
|
|
429
|
+
if (sc.intent !== q2) return failC(`carryover not regenerated (want "${q2}"): ${sc.intent}`);
|
|
430
|
+
if (!Array.isArray(sc.files) || sc.files.length !== 0) return failC(`files[] not reset on regen: ${JSON.stringify(sc.files)}`);
|
|
431
|
+
if (!sc._intent_hash) return failC('regen did not install _intent_hash');
|
|
432
|
+
if (!sc.trace || sc.trace.query !== q2) return failC('regen did not record trace.query');
|
|
433
|
+
runHook(hook('final-review'), { conversation_id: cidC, status: 'completed' });
|
|
434
|
+
rmSync(transcriptC, { force: true });
|
|
435
|
+
|
|
436
|
+
// --- Case D: MODEL-written scope (no _intent_hash) + the SAME prompt ------
|
|
437
|
+
// The model wrote the contract for THIS request this session. The hook must
|
|
438
|
+
// HEAL in place (backfill _intent_hash + trace) WITHOUT resetting files[] or
|
|
439
|
+
// acceptance, so the NEXT prompt change is detectable by hash.
|
|
440
|
+
const cidD = 'npxv6';
|
|
441
|
+
const transcriptD = join(HOME, '.cursor', '.hooks-pending', 'verify-transcript-npxv6.jsonl');
|
|
442
|
+
const failD = (detail) => { try { rmSync(transcriptD, { force: true }); } catch {} cleanup(); return { ok: false, detail }; };
|
|
443
|
+
try { rmSync(scopePath, { force: true }); } catch {}
|
|
444
|
+
mkT(transcriptD, q1);
|
|
445
|
+
// prime last-query-<cidD>.hash with q1 (a hook-written scaffold), clear the
|
|
446
|
+
// latch, then overwrite with a model-style scope for the SAME prompt.
|
|
447
|
+
runHook(hook('intent-anchor'), { conversation_id: cidD, cwd: repoDir, transcript_path: transcriptD });
|
|
448
|
+
runHook(hook('final-review'), { conversation_id: cidD, status: 'completed' });
|
|
449
|
+
writeFileSync(scopePath, JSON.stringify({ intent: q1, files: ['a.ts', 'b.ts'], acceptance: 'keep me', allow_growth: false }, null, 2), 'utf8');
|
|
450
|
+
runHook(hook('intent-anchor'), { conversation_id: cidD, cwd: repoDir, transcript_path: transcriptD });
|
|
451
|
+
drainedOf(cidD);
|
|
452
|
+
let sd;
|
|
453
|
+
try { sd = JSON.parse(readFileSync(scopePath, 'utf8')); } catch { return failD('healed scope corrupted'); }
|
|
454
|
+
if (!sd._intent_hash) return failD('heal did not backfill _intent_hash');
|
|
455
|
+
if (!sd.trace) return failD('heal did not add trace');
|
|
456
|
+
if (!Array.isArray(sd.files) || sd.files.join(',') !== 'a.ts,b.ts') return failD(`heal reset files[] (should preserve): ${JSON.stringify(sd.files)}`);
|
|
457
|
+
if (sd.acceptance !== 'keep me') return failD(`heal clobbered acceptance: ${sd.acceptance}`);
|
|
458
|
+
runHook(hook('final-review'), { conversation_id: cidD, status: 'completed' });
|
|
459
|
+
rmSync(transcriptD, { force: true });
|
|
460
|
+
|
|
392
461
|
cleanup();
|
|
393
462
|
return true;
|
|
394
463
|
});
|
|
@@ -122,5 +122,18 @@ Determinism / purity:
|
|
|
122
122
|
- In-place mutation of shared state (arr.push, obj.prop =) when a caller holds
|
|
123
123
|
a reference -> return new structures ([...arr, x], .map/.filter).
|
|
124
124
|
|
|
125
|
+
Logic & structure:
|
|
126
|
+
- Arrow code: >2 levels of nested if/for -> flatten with guard clauses
|
|
127
|
+
(early returns). Code reads top-to-bottom, no deep indent.
|
|
128
|
+
- Switch/if-else bloat: a switch or 5+ if/else branches -> Map/dispatch
|
|
129
|
+
(Record<State, fn>) or the Command pattern.
|
|
130
|
+
- Mixed abstraction (SLAP): a function mixing DB calls + string validation +
|
|
131
|
+
date formatting -> one level of abstraction per function; extract helpers.
|
|
132
|
+
- Primitive obsession: a primitive with business rules (email, userId, chainId)
|
|
133
|
+
passed as a bare string/number across functions -> a named type/value object.
|
|
134
|
+
- Imperative transforms: a `for` loop building an array when the language has
|
|
135
|
+
.map/.filter/.reduce -> use the declarative form; reserve `for` for cases
|
|
136
|
+
map/reduce cannot express.
|
|
137
|
+
|
|
125
138
|
You do NOT need to run a tool for these — read the diff and apply the named fix.
|
|
126
139
|
If none apply, say so in one line.
|
|
@@ -110,7 +110,7 @@ body="$(expand_agent_paths "$body")"
|
|
|
110
110
|
# wrong diff. Reset its prior to the Anchor Set, not to its previous attempt.
|
|
111
111
|
reentry_line="
|
|
112
112
|
|
|
113
|
-
RE-ENTRY RULE (Regla R1): if a gate or axis failed, forget the approach that produced it. Re-read your ORIGINAL REQUEST above and your Anchor Set (.scope.json,
|
|
113
|
+
RE-ENTRY RULE (Regla R1): if a gate or axis failed, forget the approach that produced it. Re-read your ORIGINAL REQUEST above and your Anchor Set (.scope.json, maintained by the intent-anchor hook). Fix ONLY what is failing. Do not refactor in this pass - that is History Propagation, the exact failure mode the Anchor Set exists to prevent.
|
|
114
114
|
"
|
|
115
115
|
|
|
116
116
|
file_list=""
|
|
@@ -58,6 +58,16 @@ pending_dir="$(hooks_pending_dir)"
|
|
|
58
58
|
latch="$pending_dir/intent-injected-$cid.flag"
|
|
59
59
|
hash_file="$pending_dir/last-query-$cid.hash"
|
|
60
60
|
|
|
61
|
+
# Stale-latch defense: if a previous session died mid-turn without hitting
|
|
62
|
+
# stop (Cursor crash, force-quit), the latch can persist and silence this hook
|
|
63
|
+
# for the whole next session -> scope never gets created. If the latch is older
|
|
64
|
+
# than 2 hours, treat it as orphaned and clear it. Normal clears happen at
|
|
65
|
+
# every stop (final-review.sh); this is the backstop for abnormal terminations.
|
|
66
|
+
if [ -f "$latch" ]; then
|
|
67
|
+
age_hours=$(( ($(date +%s) - $(stat -c %Y "$latch" 2>/dev/null || stat -f %m "$latch" 2>/dev/null || echo 0)) / 3600 ))
|
|
68
|
+
[ "$age_hours" -ge 2 ] && rm -f "$latch" 2>/dev/null
|
|
69
|
+
fi
|
|
70
|
+
|
|
61
71
|
# Already injected this turn -> quiet. Latch cleared at every stop.
|
|
62
72
|
[ -f "$latch" ] && exit 0
|
|
63
73
|
|
|
@@ -101,7 +111,9 @@ scope_exists=0
|
|
|
101
111
|
scope_intent=""
|
|
102
112
|
scope_acceptance=""
|
|
103
113
|
scope_files=""
|
|
104
|
-
scope_stale=0
|
|
114
|
+
scope_stale=0 # 1 when the on-disk contract belongs to a DIFFERENT prompt -> regenerate (resets files[])
|
|
115
|
+
needs_heal=0 # 1 when a model-written contract matches THIS prompt but lacks _intent_hash -> backfill in place
|
|
116
|
+
on_disk_hash=""
|
|
105
117
|
scope_path="$root/.scope.json"
|
|
106
118
|
if [ -f "$scope_path" ]; then
|
|
107
119
|
# Prefer jq; fall back to python3 (mirrors hook-common.sh degrade policy).
|
|
@@ -127,42 +139,68 @@ except Exception:
|
|
|
127
139
|
EOF
|
|
128
140
|
[ $? -eq 0 ] && scope_exists=1 || scope_exists=0
|
|
129
141
|
fi
|
|
130
|
-
#
|
|
131
|
-
|
|
132
|
-
|
|
142
|
+
# Staleness, hash-agnostic so it survives MODEL-written contracts:
|
|
143
|
+
# - hook-written (has _intent_hash): stale when that hash != current query hash.
|
|
144
|
+
# - model-written (no _intent_hash - the legacy pre-compile.md schema): fall back to
|
|
145
|
+
# $prompt_changed (current query hash != the per-conversation last-query hash). Prompt
|
|
146
|
+
# changed (or a new session) => regenerate and RESET files[] (the "arrastre entre
|
|
147
|
+
# features" fix). Same prompt this session => heal in place (backfill bookkeeping, keep
|
|
148
|
+
# files[]/acceptance) so the NEXT prompt is detected by hash.
|
|
149
|
+
if [ "$scope_exists" = "1" ] && [ "$has_query" = "1" ]; then
|
|
150
|
+
if [ -n "$on_disk_hash" ]; then
|
|
151
|
+
[ "$on_disk_hash" != "$current_hash" ] && scope_stale=1
|
|
152
|
+
elif [ "$prompt_changed" = "1" ]; then
|
|
153
|
+
scope_stale=1
|
|
154
|
+
else
|
|
155
|
+
needs_heal=1
|
|
156
|
+
fi
|
|
133
157
|
fi
|
|
134
158
|
fi
|
|
135
159
|
|
|
136
|
-
# --- auto-create / regenerate .scope.json
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
160
|
+
# --- auto-create / regenerate / heal .scope.json ----------------------------
|
|
161
|
+
# CREATION does NOT require the query: if there's a root and no scope yet,
|
|
162
|
+
# scaffold it NOW with intent=<TODO> (the agent fills it from the chat it's
|
|
163
|
+
# already responding to). This was the 0.5.3 bug - creation was gated on
|
|
164
|
+
# $hasQuery, so when Cursor didn't surface transcript_path in the first
|
|
165
|
+
# postToolUse fire, the scope never got created.
|
|
166
|
+
# REGENERATION requires the query: a prompt change is only detectable when we
|
|
167
|
+
# can read the request. A fresh scaffold resets files[] -> ".scope fresco por
|
|
168
|
+
# prompt, sin arrastre entre features."
|
|
142
169
|
regenerated=0
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
170
|
+
should_create=0
|
|
171
|
+
should_regen=0
|
|
172
|
+
[ "$scope_exists" != "1" ] && should_create=1
|
|
173
|
+
if [ "$has_query" = "1" ] && [ "$scope_exists" = "1" ] && [ "$scope_stale" = "1" ]; then
|
|
174
|
+
should_regen=1
|
|
148
175
|
fi
|
|
176
|
+
now_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
|
|
149
177
|
|
|
150
|
-
if [ "$
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
178
|
+
if [ "$should_create" = "1" ] || [ "$should_regen" = "1" ]; then
|
|
179
|
+
# intent from the query when available, else a TODO for the agent to fill.
|
|
180
|
+
# trace.query records the verbatim originating request (provenance); empty
|
|
181
|
+
# when there is no transcript to read it from.
|
|
182
|
+
if [ "$has_query" = "1" ]; then
|
|
183
|
+
intent_val="$current_query"
|
|
184
|
+
trace_query="$current_query"
|
|
185
|
+
else
|
|
186
|
+
intent_val="<TODO: state the operational objective - what is strictly necessary>"
|
|
187
|
+
trace_query=""
|
|
188
|
+
fi
|
|
189
|
+
# jq preferred; python3 fallback. Write intent, empty files[], TODO acceptance,
|
|
190
|
+
# trace provenance, and _intent_hash so staleness is self-contained.
|
|
154
191
|
if have_jq; then
|
|
155
|
-
jq -n --arg intent "$
|
|
156
|
-
'{intent:$intent, files:[], acceptance:"<TODO: the one deterministic check that decides done>", allow_growth:false, _intent_hash:$hash, _generated_by:"intent-anchor hook"}' \
|
|
192
|
+
jq -n --arg intent "$intent_val" --arg hash "$current_hash" --arg tq "$trace_query" --arg ts "$now_ts" \
|
|
193
|
+
'{intent:$intent, files:[], acceptance:"<TODO: the one deterministic check that decides done>", allow_growth:false, trace:{query:$tq, ts:$ts}, _intent_hash:$hash, _generated_by:"intent-anchor hook"}' \
|
|
157
194
|
> "$scope_path" 2>/dev/null && regenerated=1
|
|
158
195
|
elif have_py; then
|
|
159
|
-
if I_FILE="$scope_path" I_INTENT="$
|
|
196
|
+
if I_FILE="$scope_path" I_INTENT="$intent_val" I_HASH="$current_hash" I_TQ="$trace_query" I_TS="$now_ts" python3 -c '
|
|
160
197
|
import json, os
|
|
161
198
|
obj = {
|
|
162
199
|
"intent": os.environ["I_INTENT"],
|
|
163
200
|
"files": [],
|
|
164
201
|
"acceptance": "<TODO: the one deterministic check that decides done>",
|
|
165
202
|
"allow_growth": False,
|
|
203
|
+
"trace": {"query": os.environ["I_TQ"], "ts": os.environ["I_TS"]},
|
|
166
204
|
"_intent_hash": os.environ["I_HASH"],
|
|
167
205
|
"_generated_by": "intent-anchor hook",
|
|
168
206
|
}
|
|
@@ -173,7 +211,7 @@ with open(os.environ["I_FILE"], "w", encoding="utf-8") as f:
|
|
|
173
211
|
fi
|
|
174
212
|
fi
|
|
175
213
|
if [ "$regenerated" = "1" ]; then
|
|
176
|
-
scope_intent="$
|
|
214
|
+
scope_intent="$intent_val"
|
|
177
215
|
scope_acceptance="<TODO: the one deterministic check that decides done>"
|
|
178
216
|
scope_files="(auto-tracked - the scope hook records every file you edit)"
|
|
179
217
|
scope_exists=1
|
|
@@ -181,6 +219,35 @@ with open(os.environ["I_FILE"], "w", encoding="utf-8") as f:
|
|
|
181
219
|
fi
|
|
182
220
|
fi
|
|
183
221
|
|
|
222
|
+
# HEAL a model-written contract that matches the current prompt but lacks the
|
|
223
|
+
# hook's bookkeeping: backfill _intent_hash + trace + _generated_by IN PLACE,
|
|
224
|
+
# preserving the model's files[] and acceptance. Without this a contract written
|
|
225
|
+
# per the legacy pre-compile.md schema (no _intent_hash) can never go stale, so
|
|
226
|
+
# the next prompt never regenerates - the carryover bug. Healing installs the
|
|
227
|
+
# hash so the next prompt change is detected by hash like any hook contract.
|
|
228
|
+
if [ "$needs_heal" = "1" ] && [ "$regenerated" != "1" ]; then
|
|
229
|
+
if have_jq; then
|
|
230
|
+
healed="$(jq --arg hash "$current_hash" --arg tq "$current_query" --arg ts "$now_ts" \
|
|
231
|
+
'._intent_hash = $hash | .trace //= {query:$tq, ts:$ts} | ._generated_by //= "intent-anchor hook (healed)"' \
|
|
232
|
+
"$scope_path" 2>/dev/null)"
|
|
233
|
+
[ -n "$healed" ] && printf '%s\n' "$healed" > "$scope_path"
|
|
234
|
+
elif have_py; then
|
|
235
|
+
I_FILE="$scope_path" I_HASH="$current_hash" I_TQ="$current_query" I_TS="$now_ts" python3 -c '
|
|
236
|
+
import json, os, sys
|
|
237
|
+
path = os.environ["I_FILE"]
|
|
238
|
+
try:
|
|
239
|
+
d = json.load(open(path, encoding="utf-8"))
|
|
240
|
+
except Exception:
|
|
241
|
+
sys.exit(0)
|
|
242
|
+
d["_intent_hash"] = os.environ["I_HASH"]
|
|
243
|
+
d.setdefault("trace", {"query": os.environ["I_TQ"], "ts": os.environ["I_TS"]})
|
|
244
|
+
d.setdefault("_generated_by", "intent-anchor hook (healed)")
|
|
245
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
246
|
+
json.dump(d, f, ensure_ascii=False, indent=2)
|
|
247
|
+
' 2>/dev/null
|
|
248
|
+
fi
|
|
249
|
+
fi
|
|
250
|
+
|
|
184
251
|
# files[] is auto-tracked and starts empty; show something readable until the
|
|
185
252
|
# scope hook has recorded the first edit.
|
|
186
253
|
[ -n "$scope_files" ] || scope_files="(none yet - auto-tracked as you edit)"
|
package/linux/pre-compile.md
CHANGED
|
@@ -29,29 +29,41 @@ Answer these four, terse, in your first response. One phrase each, not prose:
|
|
|
29
29
|
specific failing test going green is. If you cannot name one, you do not
|
|
30
30
|
yet understand the task — ask.
|
|
31
31
|
|
|
32
|
-
## Materialize it: .scope.json
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
## Materialize it: .scope.json (the hook owns this file)
|
|
33
|
+
|
|
34
|
+
The `intent-anchor` hook creates and maintains `.scope.json` in the repo root for
|
|
35
|
+
you, automatically, on the first tool of every turn:
|
|
36
|
+
- `intent` is locked from your current request and REFRESHED when the request
|
|
37
|
+
changes — a new prompt regenerates the contract and resets `files[]`, so it
|
|
38
|
+
never carries over between features;
|
|
39
|
+
- `files[]` is auto-recorded — the scope hook appends every file you edit, so
|
|
40
|
+
you never maintain it by hand;
|
|
41
|
+
- `trace`, `_intent_hash`, `_generated_by` are hook bookkeeping (provenance and
|
|
42
|
+
change detection). Leave them alone.
|
|
43
|
+
|
|
44
|
+
Your one job on the contract: set **`acceptance`** — the single deterministic check
|
|
45
|
+
that decides done — which the hook cannot derive. Edit ONLY that field (a targeted
|
|
46
|
+
string replace). Do **NOT** rewrite the whole file: overwriting it drops the hook's
|
|
47
|
+
`_intent_hash`/`trace`, which disables per-prompt regeneration and brings back the
|
|
48
|
+
cross-feature carryover. The scope-gate hook audits every edit against `files[]`,
|
|
49
|
+
and final-review axis 0 traces every diff hunk back to `intent`.
|
|
38
50
|
|
|
39
51
|
```json
|
|
40
52
|
{
|
|
41
|
-
"intent":
|
|
42
|
-
"files":
|
|
43
|
-
"acceptance":
|
|
44
|
-
"allow_growth": false
|
|
53
|
+
"intent": "<locked from your request by the hook>",
|
|
54
|
+
"files": ["<auto-recorded by the hook as you edit>"],
|
|
55
|
+
"acceptance": "<YOU set this: the deterministic check that decides done>",
|
|
56
|
+
"allow_growth": false,
|
|
57
|
+
"trace": { "query": "<originating request>", "ts": "<when>" },
|
|
58
|
+
"_intent_hash": "<hook bookkeeping>",
|
|
59
|
+
"_generated_by":"intent-anchor hook"
|
|
45
60
|
}
|
|
46
61
|
```
|
|
47
62
|
|
|
48
|
-
`allow_growth: false` is the default
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
No need to write `.scope.json` for trivial one-liners (a typo, a literal).
|
|
53
|
-
The declared-editing ladder's rung 1 ("does this need to exist?") governs when
|
|
54
|
-
the Anchor Set itself is overkill. When in doubt, write it.
|
|
63
|
+
`allow_growth: false` is the default. If the contract has not appeared yet (the
|
|
64
|
+
hook scaffolds on a tool boundary), just proceed — you do not need to hand-write
|
|
65
|
+
it. The declared-editing ladder's rung 1 ("does this need to exist?") still governs
|
|
66
|
+
trivial one-liners.
|
|
55
67
|
|
|
56
68
|
## Regla R3 — Authority
|
|
57
69
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursordoctrine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Thin self-review hooks for Cursor — the model is the auditor. Pruned + deduplicated: intent-anchor (auto-scaffolded .scope.json per prompt + per-turn re-injection against Salience Dilution), intent-trace final review, unified anti-slop checklist as single source of truth.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cursordoctrine": "bin/cli.mjs"
|
|
@@ -8,12 +8,16 @@ declared-scope check (Step C), so the two never disagree on what counts as
|
|
|
8
8
|
"in scope". It also surfaces the contract's `intent` and `acceptance` fields
|
|
9
9
|
so the calling hook can quote them back to the agent.
|
|
10
10
|
|
|
11
|
-
.scope.json schema (intent
|
|
11
|
+
.scope.json schema (the intent-anchor hook owns this file; this matcher reads
|
|
12
|
+
only intent/files/acceptance/allow_growth and ignores the hook bookkeeping):
|
|
12
13
|
{
|
|
13
14
|
"intent": "one operational sentence of objective",
|
|
14
15
|
"files": [ "repo-relative globs", ... ],
|
|
15
16
|
"acceptance": "the deterministic check that decides success",
|
|
16
|
-
"allow_growth": false
|
|
17
|
+
"allow_growth": false,
|
|
18
|
+
"trace": { "query": "originating request", "ts": "ISO-8601" },
|
|
19
|
+
"_intent_hash": "hook bookkeeping: sha256 of the request, drives per-prompt regen",
|
|
20
|
+
"_generated_by":"intent-anchor hook"
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
Pattern support:
|
|
@@ -122,5 +122,18 @@ Determinism / purity:
|
|
|
122
122
|
- In-place mutation of shared state (arr.push, obj.prop =) when a caller holds
|
|
123
123
|
a reference -> return new structures ([...arr, x], .map/.filter).
|
|
124
124
|
|
|
125
|
+
Logic & structure:
|
|
126
|
+
- Arrow code: >2 levels of nested if/for -> flatten with guard clauses
|
|
127
|
+
(early returns). Code reads top-to-bottom, no deep indent.
|
|
128
|
+
- Switch/if-else bloat: a switch or 5+ if/else branches -> Map/dispatch
|
|
129
|
+
(Record<State, fn>) or the Command pattern.
|
|
130
|
+
- Mixed abstraction (SLAP): a function mixing DB calls + string validation +
|
|
131
|
+
date formatting -> one level of abstraction per function; extract helpers.
|
|
132
|
+
- Primitive obsession: a primitive with business rules (email, userId, chainId)
|
|
133
|
+
passed as a bare string/number across functions -> a named type/value object.
|
|
134
|
+
- Imperative transforms: a `for` loop building an array when the language has
|
|
135
|
+
.map/.filter/.reduce -> use the declarative form; reserve `for` for cases
|
|
136
|
+
map/reduce cannot express.
|
|
137
|
+
|
|
125
138
|
You do NOT need to run a tool for these — read the diff and apply the named fix.
|
|
126
139
|
If none apply, say so in one line.
|
|
@@ -119,7 +119,7 @@ $body = Expand-AgentPaths $body
|
|
|
119
119
|
# Regla R1 (re-entry): if this review pass is a re-audit after a failed gate or
|
|
120
120
|
# axis, suppress History Propagation - the model must NOT build on its own prior
|
|
121
121
|
# wrong diff. Reset its prior to the Anchor Set, not to its previous attempt.
|
|
122
|
-
$reentryLine = "`n`nRE-ENTRY RULE (Regla R1): if a gate or axis failed, forget the approach that produced it. Re-read your ORIGINAL REQUEST above and your Anchor Set (.scope.json,
|
|
122
|
+
$reentryLine = "`n`nRE-ENTRY RULE (Regla R1): if a gate or axis failed, forget the approach that produced it. Re-read your ORIGINAL REQUEST above and your Anchor Set (.scope.json, maintained by the intent-anchor hook). Fix ONLY what is failing. Do not refactor in this pass - that is History Propagation, the exact failure mode the Anchor Set exists to prevent.`n"
|
|
123
123
|
|
|
124
124
|
$resolved = @($edited | ForEach-Object { Resolve-AgentPath $_ })
|
|
125
125
|
$fileList = ($resolved | Select-Object -First 30) -join "`n "
|
|
@@ -14,17 +14,20 @@
|
|
|
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. AUTO-CREATE
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
17
|
+
# 2. AUTO-CREATE .scope.json (UNCONDITIONAL on a real root): if no valid
|
|
18
|
+
# contract exists in the repo root, WRITE one now - intent locked from the
|
|
19
|
+
# query when available, otherwise `intent: <TODO>` for the agent to fill.
|
|
20
|
+
# Creation does NOT require transcript_path; only regeneration does. This
|
|
21
|
+
# was the 0.5.3 bug: creation was gated on $hasQuery, so when Cursor didn't
|
|
22
|
+
# surface the transcript on the first postToolUse fire, the scope never
|
|
23
|
+
# appeared and the agent had no contract to work from.
|
|
24
|
+
# 3. REGENERATE on prompt CHANGE: when the current <user_query> hash differs
|
|
25
|
+
# from the contract's _intent_hash, overwrite the scaffold with the new
|
|
26
|
+
# intent + empty files + TODO acceptance. Requires $hasQuery (you can only
|
|
27
|
+
# detect a change if you can read the request). Fixed vs the broken 0.4.4
|
|
28
|
+
# build: never writes to $HOME (bails if no real root resolves -> no
|
|
29
|
+
# ghost files).
|
|
30
|
+
# 4. RE-INJECT on same-prompt turns: when the query is unchanged (contract
|
|
28
31
|
# already current), the hook re-injects the existing contract into the
|
|
29
32
|
# feedback bus so it stays in the model's attentional focus each turn.
|
|
30
33
|
#
|
|
@@ -56,6 +59,16 @@ $pendingDir = Get-HooksPendingDir
|
|
|
56
59
|
$latch = Join-Path $pendingDir "intent-injected-$cid.flag"
|
|
57
60
|
$hashFile = Join-Path $pendingDir "last-query-$cid.hash"
|
|
58
61
|
|
|
62
|
+
# Stale-latch defense: if a previous session died mid-turn without hitting
|
|
63
|
+
# stop (Cursor crash, force-quit), the latch can persist and silence this hook
|
|
64
|
+
# for the whole next session -> scope never gets created. If the latch is older
|
|
65
|
+
# than 2 hours, treat it as orphaned and clear it. Normal clears happen at
|
|
66
|
+
# every stop (final-review.ps1); this is the backstop for abnormal terminations.
|
|
67
|
+
if (Test-Path $latch) {
|
|
68
|
+
$age = (Get-Date) - (Get-Item $latch).LastWriteTime
|
|
69
|
+
if ($age.TotalHours -ge 2) { Remove-Item $latch -Force -ErrorAction SilentlyContinue }
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
# Already injected this turn -> quiet. Latch cleared at every stop.
|
|
60
73
|
if (Test-Path $latch) { exit 0 }
|
|
61
74
|
|
|
@@ -96,7 +109,9 @@ $scopeExists = $false
|
|
|
96
109
|
$scopeIntent = ''
|
|
97
110
|
$scopeAcceptance = ''
|
|
98
111
|
$scopeFiles = ''
|
|
99
|
-
$scopeStale = $false
|
|
112
|
+
$scopeStale = $false # true when the on-disk contract belongs to a DIFFERENT prompt -> regenerate (resets files[])
|
|
113
|
+
$needsHeal = $false # true when a model-written contract matches THIS prompt but lacks _intent_hash -> backfill in place
|
|
114
|
+
$scopeHasHash = $false
|
|
100
115
|
$scopePath = Join-Path $root '.scope.json'
|
|
101
116
|
if (Test-Path -LiteralPath $scopePath) {
|
|
102
117
|
try {
|
|
@@ -106,39 +121,58 @@ if (Test-Path -LiteralPath $scopePath) {
|
|
|
106
121
|
if ($sj.files) { $scopeFiles = (@($sj.files) -join ', ') }
|
|
107
122
|
if ([string]::IsNullOrWhiteSpace($scopeFiles)) { $scopeFiles = '(none yet - auto-tracked as you edit)' }
|
|
108
123
|
$scopeExists = $true
|
|
109
|
-
|
|
110
|
-
# hash
|
|
111
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
124
|
+
$scopeHasHash = ($sj.PSObject.Properties['_intent_hash'] -and -not [string]::IsNullOrWhiteSpace([string]$sj._intent_hash))
|
|
125
|
+
# Staleness, hash-agnostic so it survives MODEL-written contracts:
|
|
126
|
+
# - hook-written (has _intent_hash): stale when that hash != current query hash.
|
|
127
|
+
# - model-written (no _intent_hash - the legacy pre-compile.md schema): we cannot
|
|
128
|
+
# hash-compare, so fall back to $promptChanged (current query hash != the per-
|
|
129
|
+
# conversation last-query hash). Prompt changed (or a new session) => stale ->
|
|
130
|
+
# regenerate and RESET files[]; this is the "arrastre entre features" fix (a model-
|
|
131
|
+
# written scope could never go stale, so it never refreshed and scope-gate kept
|
|
132
|
+
# appending files across unrelated features). Same prompt this session => the model
|
|
133
|
+
# wrote it for THIS request; heal in place (backfill the bookkeeping, keep its
|
|
134
|
+
# files[]/acceptance) so the NEXT prompt is detected by hash like any hook contract.
|
|
135
|
+
if ($hasQuery) {
|
|
136
|
+
if ($scopeHasHash) {
|
|
137
|
+
$scopeStale = ([string]$sj._intent_hash -ne $currentHash)
|
|
138
|
+
} elseif ($promptChanged) {
|
|
139
|
+
$scopeStale = $true
|
|
140
|
+
} else {
|
|
141
|
+
$needsHeal = $true
|
|
142
|
+
}
|
|
114
143
|
}
|
|
115
144
|
} catch { $scopeExists = $false } # malformed JSON -> treat as missing
|
|
116
145
|
}
|
|
117
146
|
|
|
118
|
-
# --- auto-create / regenerate .scope.json
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
147
|
+
# --- auto-create / regenerate / heal .scope.json ----------------------------
|
|
148
|
+
# CREATION does NOT require the query: if there's a root and no scope yet,
|
|
149
|
+
# scaffold it NOW with intent=<TODO> (the agent fills it from the chat it's
|
|
150
|
+
# already responding to). This was the 0.5.3 bug - creation was gated on
|
|
151
|
+
# $hasQuery, so when Cursor didn't surface transcript_path in the first
|
|
152
|
+
# postToolUse fire, the scope never got created. The agent never had a
|
|
153
|
+
# contract to work from.
|
|
154
|
+
# REGENERATION requires the query: a prompt change is only detectable when we
|
|
155
|
+
# can read the request. A fresh scaffold resets files[] -> ".scope fresco por
|
|
156
|
+
# prompt, sin arrastre entre features."
|
|
127
157
|
$regenerated = $false
|
|
128
|
-
$
|
|
129
|
-
|
|
158
|
+
$shouldCreate = -not $scopeExists
|
|
159
|
+
$shouldRegen = $hasQuery -and $scopeExists -and $scopeStale
|
|
160
|
+
if ($shouldCreate -or $shouldRegen) {
|
|
130
161
|
try {
|
|
162
|
+
$intentVal = if ($hasQuery) { $currentQuery } else { '<TODO: state the operational objective - what is strictly necessary>' }
|
|
163
|
+
$traceQuery = if ($hasQuery) { $currentQuery } else { '' }
|
|
131
164
|
$scaffold = [ordered]@{
|
|
132
|
-
intent = $
|
|
165
|
+
intent = $intentVal
|
|
133
166
|
files = @()
|
|
134
167
|
acceptance = '<TODO: the one deterministic check that decides done>'
|
|
135
168
|
allow_growth = $false
|
|
169
|
+
trace = [ordered]@{ query = $traceQuery; ts = (Get-Date).ToString('o') }
|
|
136
170
|
_intent_hash = $currentHash
|
|
137
171
|
_generated_by = 'intent-anchor hook'
|
|
138
172
|
}
|
|
139
|
-
$json = $scaffold | ConvertTo-Json -Depth
|
|
173
|
+
$json = $scaffold | ConvertTo-Json -Depth 8
|
|
140
174
|
[System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
|
|
141
|
-
$scopeIntent = $
|
|
175
|
+
$scopeIntent = $intentVal
|
|
142
176
|
$scopeAcceptance = '<TODO: the one deterministic check that decides done>'
|
|
143
177
|
$scopeFiles = '(auto-tracked - the scope hook records every file you edit)'
|
|
144
178
|
$scopeExists = $true
|
|
@@ -147,6 +181,26 @@ if ($shouldWrite) {
|
|
|
147
181
|
} catch { } # write failed (perms / locked) -> fall through to demand msg
|
|
148
182
|
}
|
|
149
183
|
|
|
184
|
+
# HEAL a model-written contract that matches the current prompt but lacks the
|
|
185
|
+
# hook's bookkeeping: backfill _intent_hash + trace + _generated_by IN PLACE,
|
|
186
|
+
# preserving the model's files[] and acceptance. Without this, a contract written
|
|
187
|
+
# per pre-compile.md (no _intent_hash) can never go stale, so the next prompt
|
|
188
|
+
# never regenerates - the carryover bug. Healing installs the hash so the next
|
|
189
|
+
# prompt change is detected by hash like any hook-written contract.
|
|
190
|
+
if ($needsHeal -and -not $regenerated) {
|
|
191
|
+
try {
|
|
192
|
+
$ordered = [ordered]@{}
|
|
193
|
+
foreach ($p in $sj.PSObject.Properties) { $ordered[$p.Name] = $p.Value }
|
|
194
|
+
if (-not $ordered.Contains('trace')) {
|
|
195
|
+
$ordered['trace'] = [ordered]@{ query = $currentQuery; ts = (Get-Date).ToString('o') }
|
|
196
|
+
}
|
|
197
|
+
$ordered['_intent_hash'] = $currentHash
|
|
198
|
+
if (-not $ordered.Contains('_generated_by')) { $ordered['_generated_by'] = 'intent-anchor hook (healed)' }
|
|
199
|
+
$json = $ordered | ConvertTo-Json -Depth 8
|
|
200
|
+
[System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
|
|
201
|
+
} catch { }
|
|
202
|
+
}
|
|
203
|
+
|
|
150
204
|
# --- compose the anchor message ---------------------------------------------
|
|
151
205
|
# Three states: regenerated this turn (new prompt), no contract (and no query
|
|
152
206
|
# to scaffold from), or re-injecting an existing current contract.
|
package/windows/pre-compile.md
CHANGED
|
@@ -29,29 +29,41 @@ Answer these four, terse, in your first response. One phrase each, not prose:
|
|
|
29
29
|
specific failing test going green is. If you cannot name one, you do not
|
|
30
30
|
yet understand the task — ask.
|
|
31
31
|
|
|
32
|
-
## Materialize it: .scope.json
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
## Materialize it: .scope.json (the hook owns this file)
|
|
33
|
+
|
|
34
|
+
The `intent-anchor` hook creates and maintains `.scope.json` in the repo root for
|
|
35
|
+
you, automatically, on the first tool of every turn:
|
|
36
|
+
- `intent` is locked from your current request and REFRESHED when the request
|
|
37
|
+
changes — a new prompt regenerates the contract and resets `files[]`, so it
|
|
38
|
+
never carries over between features;
|
|
39
|
+
- `files[]` is auto-recorded — the scope hook appends every file you edit, so
|
|
40
|
+
you never maintain it by hand;
|
|
41
|
+
- `trace`, `_intent_hash`, `_generated_by` are hook bookkeeping (provenance and
|
|
42
|
+
change detection). Leave them alone.
|
|
43
|
+
|
|
44
|
+
Your one job on the contract: set **`acceptance`** — the single deterministic check
|
|
45
|
+
that decides done — which the hook cannot derive. Edit ONLY that field (a targeted
|
|
46
|
+
string replace). Do **NOT** rewrite the whole file: overwriting it drops the hook's
|
|
47
|
+
`_intent_hash`/`trace`, which disables per-prompt regeneration and brings back the
|
|
48
|
+
cross-feature carryover. The scope-gate hook audits every edit against `files[]`,
|
|
49
|
+
and final-review axis 0 traces every diff hunk back to `intent`.
|
|
38
50
|
|
|
39
51
|
```json
|
|
40
52
|
{
|
|
41
|
-
"intent":
|
|
42
|
-
"files":
|
|
43
|
-
"acceptance":
|
|
44
|
-
"allow_growth": false
|
|
53
|
+
"intent": "<locked from your request by the hook>",
|
|
54
|
+
"files": ["<auto-recorded by the hook as you edit>"],
|
|
55
|
+
"acceptance": "<YOU set this: the deterministic check that decides done>",
|
|
56
|
+
"allow_growth": false,
|
|
57
|
+
"trace": { "query": "<originating request>", "ts": "<when>" },
|
|
58
|
+
"_intent_hash": "<hook bookkeeping>",
|
|
59
|
+
"_generated_by":"intent-anchor hook"
|
|
45
60
|
}
|
|
46
61
|
```
|
|
47
62
|
|
|
48
|
-
`allow_growth: false` is the default
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
No need to write `.scope.json` for trivial one-liners (a typo, a literal).
|
|
53
|
-
The declared-editing ladder's rung 1 ("does this need to exist?") governs when
|
|
54
|
-
the Anchor Set itself is overkill. When in doubt, write it.
|
|
63
|
+
`allow_growth: false` is the default. If the contract has not appeared yet (the
|
|
64
|
+
hook scaffolds on a tool boundary), just proceed — you do not need to hand-write
|
|
65
|
+
it. The declared-editing ladder's rung 1 ("does this need to exist?") still governs
|
|
66
|
+
trivial one-liners.
|
|
55
67
|
|
|
56
68
|
## Regla R3 — Authority
|
|
57
69
|
|