cursordoctrine 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL.md +1 -1
- package/README.md +14 -1
- package/bin/cli.mjs +85 -0
- package/linux/hooks/final-review.sh +10 -6
- package/linux/hooks/intent-anchor.sh +225 -0
- package/linux/hooks/subagent-stop-review.sh +5 -4
- package/linux/hooks.json +5 -0
- package/package.json +2 -2
- package/windows/hooks/final-review.ps1 +12 -9
- package/windows/hooks/intent-anchor.ps1 +208 -0
- package/windows/hooks/subagent-stop-review.ps1 +6 -5
- package/windows/hooks.json +5 -0
package/INSTALL.md
CHANGED
|
@@ -120,4 +120,4 @@ Also validate the config: `~/.cursor/hooks.json` must parse as JSON.
|
|
|
120
120
|
|
|
121
121
|
Tell the user what was installed, which checks passed, and anything that failed with the exact error. Do not silently work around a failing check.
|
|
122
122
|
|
|
123
|
-
Kill switches if something misbehaves: `HOOKS_ENFORCE=0` (everything advisory off), `PERM_GATE_ENFORCE=0`, `ANCHOR_NUDGE_ENFORCE=0` (pre-compile nudge off), `MINIMAL_EDITING_ENFORCE=0` (deprecated in 0.3.0), `SEMANTIC_DENSITY_ENFORCE=0`, `SCOPE_GATE_ENFORCE=0`, `ANTI_SLOP_ENFORCE=0`, `FINAL_REVIEW_ENFORCE=0`, `SUBAGENT_REVIEW_ENFORCE=0`.
|
|
123
|
+
Kill switches if something misbehaves: `HOOKS_ENFORCE=0` (everything advisory off), `PERM_GATE_ENFORCE=0`, `ANCHOR_NUDGE_ENFORCE=0` (pre-compile nudge off), `INTENT_ANCHOR_ENFORCE=0` (thin-intent re-injection off), `MINIMAL_EDITING_ENFORCE=0` (deprecated in 0.3.0), `SEMANTIC_DENSITY_ENFORCE=0`, `SCOPE_GATE_ENFORCE=0`, `ANTI_SLOP_ENFORCE=0`, `FINAL_REVIEW_ENFORCE=0`, `SUBAGENT_REVIEW_ENFORCE=0`.
|
package/README.md
CHANGED
|
@@ -94,12 +94,24 @@ On the **first edit of each agent turn**, `anchor-set-nudge` drops a reminder in
|
|
|
94
94
|
|
|
95
95
|
The Anchor Set is skipped for trivial one-liners (typo, literal) — the `declared-editing.md` ladder's rung 1 governs when it's overkill.
|
|
96
96
|
|
|
97
|
+
### Keeping the contract alive: `intent-anchor` (anti-Salience-Dilution)
|
|
98
|
+
|
|
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
|
+
|
|
101
|
+
`intent-anchor` (`postToolUse`, registered first so it runs before `post-tool-use` drains the bus) does three things on the **first tool boundary of every turn** (per-turn latch, cleared unconditionally at each stop):
|
|
102
|
+
|
|
103
|
+
1. **Materialize the contract (0.4.4+).** If `.scope.json` is missing or invalid and the current `<user_query>` is available, the hook **writes a scaffold to disk** — `intent` from your prompt, `files`/`acceptance` as obvious `<TODO: …>` placeholders. Contract creation is no longer probabilistic.
|
|
104
|
+
2. **Re-inject the contract.** Reads `.scope.json` and stashes `intent` + `files` + `acceptance` into the feedback bus, which `post-tool-use` delivers as `additional_context`.
|
|
105
|
+
3. **Re-compile on prompt change.** Hashes the current `<user_query>` and compares to the previous turn's hash. If the request moved and a non-scaffold contract already exists, it demands the agent **update** `.scope.json`.
|
|
106
|
+
|
|
107
|
+
Crucially, `intent-anchor` carries the **semantic** contract (`intent`/`acceptance`) into context every turn — something the path-only `scope-gate-audit` can never do. That is what makes "the agent forgot about grid symmetry while editing the right file" catchable: the symmetry requirement is re-stated in front of the model before each edit, not just checked against a file list after.
|
|
108
|
+
|
|
97
109
|
## The five flows
|
|
98
110
|
|
|
99
111
|
| Flow | Event | What happens |
|
|
100
112
|
|---|---|---|
|
|
101
113
|
| Session | `sessionStart` | `inject-doctrine` reads doctrine + user rules + declared-editing + **pre-compile** and emits them as `additional_context`. |
|
|
102
|
-
| Every turn | `postToolUse` |
|
|
114
|
+
| Every turn | `postToolUse` | **`intent-anchor`** (registered first) re-injects `.scope.json` into `additional_context` at the first tool boundary of each turn — the anti-Salience-Dilution move that keeps `intent` + `acceptance` in the model's attentional focus before edits pile up. If the prompt changed since last turn, it demands the contract be updated. Then `post-tool-use` folds subagent markers and drains the feedback file. |
|
|
103
115
|
| Shell | `beforeShellExecution` | `permission-gate` checks the command against a deny list. Allow by default, deny by list, fail open. |
|
|
104
116
|
| Edit | `afterFileEdit` + `stop` | **Proactive:** `anchor-set-nudge` fires once per turn (on its first edit) to push the Anchor Set. **Reactive:** `self-review-trigger` stashes the review prompt per edit; `minimal-edit-audit` (deprecated), `semantic-density-audit`, `scope-gate-audit` (opt-in, audits `.scope.json`), and `anti-slop-audit` append advisories when they trip; `final-review` fires one end-of-implementation six-axis pass. |
|
|
105
117
|
| Subagent | `subagentStop` | `subagent-stop-review` fires one in-subagent final review when a delegated run edited files, before the result returns to the parent. Marker-gated and flag-braked like `final-review`. |
|
|
@@ -137,6 +149,7 @@ All hooks fail open and always exit 0. Nothing here can block your session.
|
|
|
137
149
|
| `HOOKS_ENFORCE=0` | on | turns off all advisory hooks at once |
|
|
138
150
|
| `PERM_GATE_ENFORCE=0` | on | disables the permission gate |
|
|
139
151
|
| `ANCHOR_NUDGE_ENFORCE=0` | on | disables the pre-compile nudge (first-edit Anchor Set reminder) |
|
|
152
|
+
| `INTENT_ANCHOR_ENFORCE=0` | on | disables the thin-intent re-injection (per-turn `.scope.json` echo into `additional_context`) |
|
|
140
153
|
| `MINIMAL_EDITING_ENFORCE=0` | on | disables the over-edit advisory (deprecated in 0.3.0) |
|
|
141
154
|
| `SCOPE_GATE_ENFORCE=0` | on | disables the declared-scope advisory (opt-in: only fires when `.scope.json` exists) |
|
|
142
155
|
| `SEMANTIC_DENSITY_ENFORCE=0` | on | disables the semantic-opacity advisory |
|
package/bin/cli.mjs
CHANGED
|
@@ -74,6 +74,26 @@ function mergeHooks(existing, incoming, keys) {
|
|
|
74
74
|
if (i >= 0) cur[i] = entry;
|
|
75
75
|
else cur.push(entry);
|
|
76
76
|
}
|
|
77
|
+
// Re-order our entries to match the shipped hooks.json (merge used to leave
|
|
78
|
+
// stale order — e.g. post-tool-use before intent-anchor — breaking same-tool
|
|
79
|
+
// delivery of the anchor message).
|
|
80
|
+
const foreign = cur.filter((x) => x && !isOurs(x.command, keys));
|
|
81
|
+
const reordered = [];
|
|
82
|
+
const used = new Set();
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
const k = keyOf(entry.command, keys);
|
|
85
|
+
if (!k || !isOurs(entry.command, keys)) continue;
|
|
86
|
+
const found = cur.find((x) => x && keyOf(x.command, keys) === k);
|
|
87
|
+
if (found) {
|
|
88
|
+
reordered.push(found);
|
|
89
|
+
used.add(k);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const x of cur) {
|
|
93
|
+
const k = keyOf(x?.command, keys);
|
|
94
|
+
if (isOurs(x?.command, keys) && k && !used.has(k)) reordered.push(x);
|
|
95
|
+
}
|
|
96
|
+
out.hooks[event] = [...reordered, ...foreign];
|
|
77
97
|
}
|
|
78
98
|
let preserved = 0;
|
|
79
99
|
for (const entries of Object.values(out.hooks)) {
|
|
@@ -300,6 +320,70 @@ function verify() {
|
|
|
300
320
|
return true;
|
|
301
321
|
});
|
|
302
322
|
|
|
323
|
+
check('intent-anchor scaffolds .scope.json and re-injects every turn', () => {
|
|
324
|
+
const drainedOf = (cidv) => runHook(hook('post-tool-use'), { conversation_id: cidv });
|
|
325
|
+
const anchorCid = 'npxv4';
|
|
326
|
+
const scopePath = join(HOME, '.scope.json');
|
|
327
|
+
const transcriptPath = join(HOME, '.cursor', '.hooks-pending', 'verify-transcript-npxv4.jsonl');
|
|
328
|
+
const testQuery = 'fix grid symmetry and color tokens';
|
|
329
|
+
|
|
330
|
+
const cleanup = () => {
|
|
331
|
+
try { rmSync(scopePath, { force: true }); } catch {}
|
|
332
|
+
try { rmSync(transcriptPath, { force: true }); } catch {}
|
|
333
|
+
};
|
|
334
|
+
cleanup();
|
|
335
|
+
|
|
336
|
+
// Fake transcript so Get-LastUserQuery / scaffold can read the request.
|
|
337
|
+
const transcriptLine = JSON.stringify({
|
|
338
|
+
role: 'user',
|
|
339
|
+
message: { content: `<user_query>${testQuery}</user_query>` },
|
|
340
|
+
});
|
|
341
|
+
writeFileSync(transcriptPath, transcriptLine + '\n', 'utf8');
|
|
342
|
+
|
|
343
|
+
const anchorPayload = { conversation_id: anchorCid, cwd: HOME, transcript_path: transcriptPath };
|
|
344
|
+
|
|
345
|
+
// --- Case A: no .scope.json -> hook writes scaffold on disk ----------
|
|
346
|
+
runHook(hook('intent-anchor'), anchorPayload);
|
|
347
|
+
let d = drainedOf(anchorCid);
|
|
348
|
+
if (!existsSync(scopePath)) {
|
|
349
|
+
cleanup(); return { ok: false, detail: '.scope.json was not written to disk' };
|
|
350
|
+
}
|
|
351
|
+
let scope;
|
|
352
|
+
try { scope = JSON.parse(readFileSync(scopePath, 'utf8')); } catch {
|
|
353
|
+
cleanup(); return { ok: false, detail: '.scope.json scaffold is not valid JSON' };
|
|
354
|
+
}
|
|
355
|
+
if (scope.intent !== testQuery) {
|
|
356
|
+
cleanup(); return { ok: false, detail: `scaffold intent mismatch: ${scope.intent}` };
|
|
357
|
+
}
|
|
358
|
+
if (!d.includes('scaffold written') || !d.includes(testQuery)) {
|
|
359
|
+
cleanup(); return { ok: false, detail: 'scaffold branch did not inject written contract' };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- Stop clears the latch -------------------------------------------
|
|
363
|
+
runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
|
|
364
|
+
|
|
365
|
+
// --- Case B: scope exists -> re-inject contract every turn -----------
|
|
366
|
+
writeFileSync(scopePath, JSON.stringify({
|
|
367
|
+
intent: 'fix grid symmetry and color tokens',
|
|
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 });
|
|
372
|
+
d = drainedOf(anchorCid);
|
|
373
|
+
if (!d.includes('fix grid symmetry and color tokens') || !d.includes('INTENT ANCHOR')) {
|
|
374
|
+
cleanup(); return { ok: false, detail: 'contract not re-injected on turn 2' };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
runHook(hook('final-review'), { conversation_id: anchorCid, status: 'completed' });
|
|
378
|
+
runHook(hook('intent-anchor'), { conversation_id: anchorCid, cwd: HOME, transcript_path: transcriptPath });
|
|
379
|
+
d = drainedOf(anchorCid);
|
|
380
|
+
if (!d.includes('fix grid symmetry and color tokens')) {
|
|
381
|
+
cleanup(); return { ok: false, detail: 'contract not re-injected on turn 3 (latch stranded at stop)' };
|
|
382
|
+
}
|
|
383
|
+
cleanup();
|
|
384
|
+
return true;
|
|
385
|
+
});
|
|
386
|
+
|
|
303
387
|
check('doctrine injection emits additional_context', () =>
|
|
304
388
|
runHook(join(cursorDst, injectName), {}).includes('additional_context'));
|
|
305
389
|
|
|
@@ -410,6 +494,7 @@ Kill switches (environment variables, all hooks fail open)
|
|
|
410
494
|
HOOKS_ENFORCE=0 everything advisory off
|
|
411
495
|
PERM_GATE_ENFORCE=0 permission gate off
|
|
412
496
|
ANCHOR_NUDGE_ENFORCE=0 pre-compile nudge off (first-edit Anchor Set reminder)
|
|
497
|
+
INTENT_ANCHOR_ENFORCE=0 thin-intent re-injection off (per-turn .scope.json echo)
|
|
413
498
|
MINIMAL_EDITING_ENFORCE=0 over-edit advisory off (deprecated in 0.3.0)
|
|
414
499
|
SEMANTIC_DENSITY_ENFORCE=0 semantic-opacity advisory off
|
|
415
500
|
SCOPE_GATE_ENFORCE=0 declared-scope advisory off
|
|
@@ -37,16 +37,20 @@ pending_dir="$(hooks_pending_dir)"
|
|
|
37
37
|
marker="$pending_dir/session-edits-$cid.txt"
|
|
38
38
|
flag="$pending_dir/reviewed-$cid.flag"
|
|
39
39
|
anchor_flag="$pending_dir/anchor-declared-$cid.flag"
|
|
40
|
+
intent_latch="$pending_dir/intent-injected-$cid.flag"
|
|
40
41
|
|
|
41
42
|
# Sweep state from sessions that died before their stop hook ran.
|
|
42
43
|
find "$pending_dir" -maxdepth 1 -type f -mtime +7 -delete 2>/dev/null
|
|
43
44
|
|
|
44
|
-
# Unconditionally clear the
|
|
45
|
-
# turn boundary; clearing here (not only inside the reviewed-flag
|
|
46
|
-
# guarantees
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
|
|
45
|
+
# Unconditionally clear the per-turn latches so the next turn re-fires. Every
|
|
46
|
+
# stop is a turn boundary; clearing here (not only inside the reviewed-flag
|
|
47
|
+
# block below) guarantees these re-fire on the first edit/tool of the NEXT
|
|
48
|
+
# turn and can never get stranded silenced mid-session:
|
|
49
|
+
# - anchor-declared-<cid>.flag (anchor-set-nudge, first-edit reminder)
|
|
50
|
+
# - intent-injected-<cid>.flag (intent-anchor, first-tool re-injection)
|
|
51
|
+
# last-query-<cid>.hash is NOT cleared here - it persists turn-to-turn so
|
|
52
|
+
# intent-anchor can detect prompt changes; the 7-day sweep above reaps it.
|
|
53
|
+
rm -f "$anchor_flag" "$intent_latch" 2>/dev/null
|
|
50
54
|
|
|
51
55
|
# One-shot brake: the previous stop for this conversation emitted the review.
|
|
52
56
|
if [ -f "$flag" ]; then
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# intent-anchor.sh - postToolUse "thin intent compilation" anchor (Cursor, Linux).
|
|
3
|
+
#
|
|
4
|
+
# Counteracts Salience Dilution: the failure mode where the agent's original
|
|
5
|
+
# intent erodes as the conversation fills with code, logs and errors, until the
|
|
6
|
+
# token of the original request is a rounding error against the recent history
|
|
7
|
+
# and the agent drifts ("forgets" symmetry, colors, the .scope.json it wrote at
|
|
8
|
+
# prompt 1). Two jobs, both on the FIRST tool boundary of each turn (per-turn
|
|
9
|
+
# latch intent-injected-<cid>.flag, armed here, cleared at every stop):
|
|
10
|
+
#
|
|
11
|
+
# 1. RE-INJECT .scope.json (the core anti-dilution move): read the contract
|
|
12
|
+
# (intent + files + acceptance) and stash it in the feedback bus so
|
|
13
|
+
# post-tool-use.sh delivers it as additional_context at the next tool
|
|
14
|
+
# boundary. This puts the contract back in the model's attentional focus
|
|
15
|
+
# at the START of each turn's work, before edits pile up and dilute the
|
|
16
|
+
# original intent. Works UNCONDITIONALLY - no transcript needed.
|
|
17
|
+
#
|
|
18
|
+
# 2. RE-COMPILE ON PROMPT CHANGE: hash the current <user_query> (via
|
|
19
|
+
# extract_last_user_query, which reads the transcript) and compare to
|
|
20
|
+
# last-query-<cid>.hash. If they differ and a valid .scope.json exists,
|
|
21
|
+
# demand the agent UPDATE it. If no valid .scope.json exists and the query
|
|
22
|
+
# is available, WRITE a deterministic scaffold to disk (intent = query,
|
|
23
|
+
# files/acceptance = TODO placeholders) so re-injection always has real
|
|
24
|
+
# content from the first tool boundary — contract creation is not left to
|
|
25
|
+
# the LLM alone.
|
|
26
|
+
#
|
|
27
|
+
# Why postToolUse, not afterFileEdit: afterFileEdit only fires AFTER an edit
|
|
28
|
+
# exists, and Cursor has no preToolUse for file edits. postToolUse fires after
|
|
29
|
+
# EVERY tool (Read/Glob/Bash/Write/...), so its first fire of a turn is the
|
|
30
|
+
# earliest moment the agent has begun working - typically right after the first
|
|
31
|
+
# Read/Glob, before any edit. Best available injection point for "before files".
|
|
32
|
+
#
|
|
33
|
+
# Once per turn: latch armed on first fire, cleared UNCONDITIONALLY at every
|
|
34
|
+
# stop (final-review.sh). Cannot strand silenced mid-session. Registered first
|
|
35
|
+
# in the postToolUse array so it appends to the feedback bus before
|
|
36
|
+
# post-tool-use.sh drains it (same-tool delivery; if reordered, delivery slips
|
|
37
|
+
# one tool - still correct).
|
|
38
|
+
#
|
|
39
|
+
# Advisory only: never blocks, never reads the diff, ALWAYS exits 0. Appends to
|
|
40
|
+
# the shared feedback-<cid>.txt bus. Disable: HOOKS_ENFORCE=0 or INTENT_ANCHOR_ENFORCE=0.
|
|
41
|
+
|
|
42
|
+
set +e
|
|
43
|
+
. "$(dirname "$0")/hook-common.sh"
|
|
44
|
+
|
|
45
|
+
[ "${HOOKS_ENFORCE:-}" = "0" ] && exit 0
|
|
46
|
+
[ "${INTENT_ANCHOR_ENFORCE:-}" = "0" ] && exit 0
|
|
47
|
+
|
|
48
|
+
input="$(read_hook_stdin)"
|
|
49
|
+
[ -n "$input" ] || exit 0
|
|
50
|
+
|
|
51
|
+
cid="$(safe_conversation_id "$input")"
|
|
52
|
+
pending_dir="$(hooks_pending_dir)"
|
|
53
|
+
latch="$pending_dir/intent-injected-$cid.flag"
|
|
54
|
+
hash_file="$pending_dir/last-query-$cid.hash"
|
|
55
|
+
|
|
56
|
+
# Already injected this turn -> quiet. Latch cleared at every stop.
|
|
57
|
+
[ -f "$latch" ] && exit 0
|
|
58
|
+
|
|
59
|
+
# --- current request (best-effort; absent in sandboxed runs) -----------------
|
|
60
|
+
current_query="$(extract_last_user_query "$input")"
|
|
61
|
+
has_query=0
|
|
62
|
+
[ -n "$current_query" ] && has_query=1
|
|
63
|
+
|
|
64
|
+
current_hash=""
|
|
65
|
+
prompt_changed=0
|
|
66
|
+
if [ "$has_query" = "1" ]; then
|
|
67
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
68
|
+
current_hash="$(printf '%s' "$current_query" | sha256sum | awk '{print $1}')"
|
|
69
|
+
elif command -v shasum >/dev/null 2>&1; then
|
|
70
|
+
current_hash="$(printf '%s' "$current_query" | shasum -a 256 | awk '{print $1}')"
|
|
71
|
+
fi
|
|
72
|
+
prev_hash=""
|
|
73
|
+
[ -f "$hash_file" ] && prev_hash="$(cat "$hash_file" 2>/dev/null)"
|
|
74
|
+
[ "$current_hash" != "$prev_hash" ] && prompt_changed=1
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
# --- repo root (same resolution as scope-gate-audit.sh) ----------------------
|
|
78
|
+
root=""
|
|
79
|
+
while IFS= read -r cand; do
|
|
80
|
+
[ -n "$cand" ] && [ -d "$cand" ] && { root="${cand%/}"; break; }
|
|
81
|
+
done <<EOF
|
|
82
|
+
$(json_get "$input" cwd)
|
|
83
|
+
$(json_get_array "$input" workspace_roots)
|
|
84
|
+
EOF
|
|
85
|
+
[ -n "$root" ] || root="${CURSOR_PROJECT_DIR:-$HOME}"
|
|
86
|
+
root="${root%/}"
|
|
87
|
+
|
|
88
|
+
# --- read the existing contract (if any) -------------------------------------
|
|
89
|
+
scope_exists=0
|
|
90
|
+
scope_intent=""
|
|
91
|
+
scope_acceptance=""
|
|
92
|
+
scope_files=""
|
|
93
|
+
scope_path="$root/.scope.json"
|
|
94
|
+
if [ -f "$scope_path" ]; then
|
|
95
|
+
# Prefer jq; fall back to python3 (mirrors hook-common.sh degrade policy).
|
|
96
|
+
if have_jq; then
|
|
97
|
+
scope_intent="$(jq -r '.intent // empty' "$scope_path" 2>/dev/null)"
|
|
98
|
+
scope_acceptance="$(jq -r '.acceptance // empty' "$scope_path" 2>/dev/null)"
|
|
99
|
+
scope_files="$(jq -r '(.files // []) | join(", ")' "$scope_path" 2>/dev/null)"
|
|
100
|
+
scope_exists=1
|
|
101
|
+
elif have_py; then
|
|
102
|
+
read -r scope_intent scope_acceptance scope_files <<EOF
|
|
103
|
+
$(python3 -c '
|
|
104
|
+
import json, sys
|
|
105
|
+
try:
|
|
106
|
+
d = json.load(open(sys.argv[1]))
|
|
107
|
+
print(d.get("intent","") or "")
|
|
108
|
+
print(d.get("acceptance","") or "")
|
|
109
|
+
print(", ".join(d.get("files",[]) or []))
|
|
110
|
+
except Exception:
|
|
111
|
+
sys.exit(1)
|
|
112
|
+
' "$scope_path" 2>/dev/null)
|
|
113
|
+
EOF
|
|
114
|
+
[ $? -eq 0 ] && scope_exists=1 || scope_exists=0
|
|
115
|
+
fi
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
# --- deterministic scaffold (0.4.4) -------------------------------------------
|
|
119
|
+
# When the query is available and there is no valid contract, write .scope.json
|
|
120
|
+
# on disk — intent from <user_query>, TODO placeholders for files/acceptance.
|
|
121
|
+
scaffold_written=0
|
|
122
|
+
should_scaffold=0
|
|
123
|
+
[ "$has_query" = "1" ] && [ "$scope_exists" != "1" ] && should_scaffold=1
|
|
124
|
+
|
|
125
|
+
if [ "$should_scaffold" = "1" ]; then
|
|
126
|
+
if have_py; then
|
|
127
|
+
if python3 -c '
|
|
128
|
+
import json, sys
|
|
129
|
+
path, intent = sys.argv[1], sys.argv[2]
|
|
130
|
+
obj = {
|
|
131
|
+
"intent": intent,
|
|
132
|
+
"files": ["<TODO: list files>"],
|
|
133
|
+
"acceptance": "<TODO: deterministic success check>",
|
|
134
|
+
"allow_growth": False,
|
|
135
|
+
}
|
|
136
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
137
|
+
json.dump(obj, f, ensure_ascii=False)
|
|
138
|
+
' "$scope_path" "$current_query" 2>/dev/null; then
|
|
139
|
+
scaffold_written=1
|
|
140
|
+
scope_exists=1
|
|
141
|
+
scope_intent="$current_query"
|
|
142
|
+
scope_acceptance="<TODO: deterministic success check>"
|
|
143
|
+
scope_files="<TODO: list files>"
|
|
144
|
+
fi
|
|
145
|
+
fi
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
# --- compose the anchor message ---------------------------------------------
|
|
149
|
+
if [ "$has_query" = "1" ]; then
|
|
150
|
+
query_line="$current_query"
|
|
151
|
+
else
|
|
152
|
+
query_line="(current request unavailable - no transcript in this event)"
|
|
153
|
+
fi
|
|
154
|
+
|
|
155
|
+
if [ "$scaffold_written" = "1" ]; then
|
|
156
|
+
msg="INTENT ANCHOR (scaffold written to .scope.json) - contract materialized from your request.
|
|
157
|
+
|
|
158
|
+
intent: $scope_intent
|
|
159
|
+
files: $scope_files
|
|
160
|
+
acceptance: $scope_acceptance
|
|
161
|
+
|
|
162
|
+
The hook wrote this scaffold to $scope_path — intent is locked from your current
|
|
163
|
+
request. Replace the TODO placeholders with real files[] and acceptance before
|
|
164
|
+
editing source. The contract is on disk and will be re-injected every turn."
|
|
165
|
+
elif [ "$scope_exists" != "1" ]; then
|
|
166
|
+
msg="INTENT ANCHOR (pre-compile) - no .scope.json found in $root.
|
|
167
|
+
|
|
168
|
+
Current request:
|
|
169
|
+
$query_line
|
|
170
|
+
|
|
171
|
+
You have NOT compiled your Anchor Set. Before editing files, write .scope.json
|
|
172
|
+
in the repo root:
|
|
173
|
+
intent: one operational sentence (what is strictly necessary)
|
|
174
|
+
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."
|
|
193
|
+
else
|
|
194
|
+
# Same prompt continuing (or query unavailable) -> re-inject the contract.
|
|
195
|
+
if [ "$has_query" = "1" ]; then
|
|
196
|
+
drift_note="Every edit this turn must advance intent and stay inside files. acceptance is the bar for done."
|
|
197
|
+
else
|
|
198
|
+
drift_note="(request unavailable to diff against - re-injecting the contract as-is.)"
|
|
199
|
+
fi
|
|
200
|
+
msg="INTENT ANCHOR (re-injected this turn from .scope.json) - your contract. Do not drift from it.
|
|
201
|
+
|
|
202
|
+
intent: $scope_intent
|
|
203
|
+
files: $scope_files
|
|
204
|
+
acceptance: $scope_acceptance
|
|
205
|
+
|
|
206
|
+
$drift_note If a constraint above conflicts with what you are about to do, stop
|
|
207
|
+
and reconcile - the contract outranks momentum."
|
|
208
|
+
fi
|
|
209
|
+
|
|
210
|
+
# --- stash to the feedback bus (drained by post-tool-use.sh) -----------------
|
|
211
|
+
pending="$pending_dir/feedback-$cid.txt"
|
|
212
|
+
mkdir -p "$pending_dir" 2>/dev/null
|
|
213
|
+
if [ -s "$pending" ]; then
|
|
214
|
+
printf '\n\n---\n\n%s' "$msg" >> "$pending" 2>/dev/null
|
|
215
|
+
else
|
|
216
|
+
printf '%s' "$msg" >> "$pending" 2>/dev/null
|
|
217
|
+
fi
|
|
218
|
+
|
|
219
|
+
# --- arm the latch; record the query hash for next-turn change detection -----
|
|
220
|
+
touch "$latch" 2>/dev/null
|
|
221
|
+
if [ -n "$current_hash" ]; then
|
|
222
|
+
printf '%s' "$current_hash" > "$hash_file" 2>/dev/null
|
|
223
|
+
fi
|
|
224
|
+
|
|
225
|
+
exit 0
|
|
@@ -45,11 +45,12 @@ pending_dir="$(hooks_pending_dir)"
|
|
|
45
45
|
marker="$pending_dir/session-edits-$cid.txt"
|
|
46
46
|
flag="$pending_dir/reviewed-$cid.flag"
|
|
47
47
|
anchor_flag="$pending_dir/anchor-declared-$cid.flag"
|
|
48
|
+
intent_latch="$pending_dir/intent-injected-$cid.flag"
|
|
48
49
|
|
|
49
|
-
# Unconditionally clear the
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
rm -f "$anchor_flag" 2>/dev/null
|
|
50
|
+
# Unconditionally clear the per-turn latches so the next subagent run re-fires.
|
|
51
|
+
# Clearing here (not only inside the reviewed-flag block below) can never strand
|
|
52
|
+
# them silenced. last-query-<cid>.hash is kept (cross-turn prompt-change detect).
|
|
53
|
+
rm -f "$anchor_flag" "$intent_latch" 2>/dev/null
|
|
53
54
|
|
|
54
55
|
# One-shot brake: the previous subagentStop for this id emitted the review.
|
|
55
56
|
if [ -f "$flag" ]; then
|
package/linux/hooks.json
CHANGED
|
@@ -47,6 +47,11 @@
|
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
49
|
"postToolUse": [
|
|
50
|
+
{
|
|
51
|
+
"command": "bash ~/.agents/hooks/intent-anchor.sh",
|
|
52
|
+
"timeout": 5,
|
|
53
|
+
"_comment": "5s: THIN INTENT COMPILATION (anti Salience Dilution). Registered FIRST so it appends to the feedback bus before post-tool-use.sh drains it (same-tool delivery). On the FIRST tool boundary of each turn (per-turn latch intent-injected-<cid>.flag, cleared unconditionally at every stop), (1) re-injects the existing .scope.json (intent/files/acceptance) into additional_context so the contract is back in the model's attentional focus before edits pile up - UNCONDITIONAL, no transcript needed; (2) if the current <user_query> hash differs from last-query-<cid>.hash, demands the agent UPDATE .scope.json to match the new request (scope tracks the request). No .scope.json -> demand one be written. Needs transcript_path for change-detection; degrades to silent there but re-injection still runs. Advisory only; never blocks. Disable: HOOKS_ENFORCE=0 or INTENT_ANCHOR_ENFORCE=0."
|
|
54
|
+
},
|
|
50
55
|
{
|
|
51
56
|
"command": "bash ~/.agents/hooks/post-tool-use.sh",
|
|
52
57
|
"timeout": 5,
|
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.
|
|
3
|
+
"version": "0.4.4",
|
|
4
|
+
"description": "Thin self-review hooks for Cursor — the model is the auditor. Proactive intent compilation (pre-compile Anchor Set + per-turn .scope.json re-injection against Salience Dilution), intent-trace final review (Tier 0), unified 13-item anti-slop checklist, operational slop detection.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cursordoctrine": "bin/cli.mjs"
|
|
7
7
|
},
|
|
@@ -40,7 +40,8 @@ $cid = Get-SafeConversationId $obj
|
|
|
40
40
|
$pendingDir = Get-HooksPendingDir
|
|
41
41
|
$marker = Join-Path $pendingDir "session-edits-$cid.txt"
|
|
42
42
|
$flag = Join-Path $pendingDir "reviewed-$cid.flag"
|
|
43
|
-
$anchorFlag
|
|
43
|
+
$anchorFlag = Join-Path $pendingDir "anchor-declared-$cid.flag"
|
|
44
|
+
$intentLatch = Join-Path $pendingDir "intent-injected-$cid.flag"
|
|
44
45
|
|
|
45
46
|
# Sweep state from sessions that died before their stop hook ran. Cheap (one
|
|
46
47
|
# directory listing on an event that fires once per agent loop).
|
|
@@ -48,14 +49,16 @@ Get-ChildItem $pendingDir -File -ErrorAction SilentlyContinue |
|
|
|
48
49
|
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-7) } |
|
|
49
50
|
Remove-Item -Force -ErrorAction SilentlyContinue
|
|
50
51
|
|
|
51
|
-
# Unconditionally clear the
|
|
52
|
-
# turn boundary; clearing here (not only inside the reviewed-flag
|
|
53
|
-
# guarantees
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
|
|
52
|
+
# Unconditionally clear the per-turn latches so the next turn re-fires. Every
|
|
53
|
+
# stop is a turn boundary; clearing here (not only inside the reviewed-flag
|
|
54
|
+
# block below) guarantees these re-fire on the first edit/tool of the NEXT
|
|
55
|
+
# turn and can never get stranded silenced mid-session:
|
|
56
|
+
# - anchor-declared-<cid>.flag (anchor-set-nudge, first-edit reminder)
|
|
57
|
+
# - intent-injected-<cid>.flag (intent-anchor, first-tool re-injection)
|
|
58
|
+
# last-query-<cid>.hash is NOT cleared here - it must persist turn-to-turn so
|
|
59
|
+
# intent-anchor can detect prompt changes; the 7-day sweep above reaps it when
|
|
60
|
+
# a conversation truly dies.
|
|
61
|
+
Remove-Item $anchorFlag, $intentLatch -Force -ErrorAction SilentlyContinue
|
|
59
62
|
|
|
60
63
|
# One-shot brake: the previous stop for this conversation emitted the review.
|
|
61
64
|
# Clear the flag (and whatever the review pass itself edited) and end the loop.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# intent-anchor.ps1 - postToolUse "thin intent compilation" anchor (Cursor).
|
|
2
|
+
#
|
|
3
|
+
# Counteracts Salience Dilution: the failure mode where the agent's original
|
|
4
|
+
# intent erodes as the conversation fills with code, logs and errors, until the
|
|
5
|
+
# token of the original request is a rounding error against the recent history
|
|
6
|
+
# and the agent drifts ("forgets" symmetry, colors, the .scope.json it wrote at
|
|
7
|
+
# prompt 1). Two jobs, both on the FIRST tool boundary of each turn (per-turn
|
|
8
|
+
# latch intent-injected-<cid>.flag, armed here, cleared at every stop):
|
|
9
|
+
#
|
|
10
|
+
# 1. RE-INJECT .scope.json (the core anti-dilution move): read the contract
|
|
11
|
+
# (intent + files + acceptance) and stash it in the feedback bus so
|
|
12
|
+
# post-tool-use.ps1 delivers it as additional_context at the next tool
|
|
13
|
+
# boundary. This puts the contract back in the model's attentional focus
|
|
14
|
+
# at the START of each turn's work, before edits pile up and dilute the
|
|
15
|
+
# original intent. Works UNCONDITIONALLY - no transcript needed.
|
|
16
|
+
#
|
|
17
|
+
# 2. RE-COMPILE ON PROMPT CHANGE: hash the current <user_query> (via
|
|
18
|
+
# Get-LastUserQuery, which reads the transcript) and compare to
|
|
19
|
+
# last-query-<cid>.hash. If they differ and a valid .scope.json exists,
|
|
20
|
+
# demand the agent UPDATE it. If no valid .scope.json exists and the query
|
|
21
|
+
# is available, WRITE a deterministic scaffold to disk (intent = query,
|
|
22
|
+
# files/acceptance = TODO placeholders) so re-injection always has real
|
|
23
|
+
# content from the first tool boundary — contract creation is not left to
|
|
24
|
+
# the LLM alone.
|
|
25
|
+
#
|
|
26
|
+
# Why postToolUse, not afterFileEdit: afterFileEdit only fires AFTER an edit
|
|
27
|
+
# exists, and Cursor has no preToolUse for file edits. postToolUse fires after
|
|
28
|
+
# EVERY tool (Read/Glob/Bash/Write/...), so its first fire of a turn is the
|
|
29
|
+
# earliest moment the agent has begun working - typically right after the first
|
|
30
|
+
# Read/Glob, before any edit. Best available injection point for "before files".
|
|
31
|
+
#
|
|
32
|
+
# Once per turn: latch armed on first fire, cleared UNCONDITIONALLY at every
|
|
33
|
+
# stop (final-review.ps1). Cannot strand silenced mid-session (that was the
|
|
34
|
+
# 0.4.0 bug). Registered first in the postToolUse array so it appends to the
|
|
35
|
+
# feedback bus before post-tool-use.ps1 drains it (same-tool delivery; if an
|
|
36
|
+
# updated install orders it after, delivery slips one tool - still correct).
|
|
37
|
+
#
|
|
38
|
+
# Advisory only: never blocks, never reads the diff, ALWAYS exits 0. Appends to
|
|
39
|
+
# the shared feedback-<cid>.txt bus. Disable: HOOKS_ENFORCE=0 or INTENT_ANCHOR_ENFORCE=0.
|
|
40
|
+
|
|
41
|
+
$ErrorActionPreference = 'SilentlyContinue'
|
|
42
|
+
. "$PSScriptRoot\hook-common.ps1"
|
|
43
|
+
|
|
44
|
+
if ($env:HOOKS_ENFORCE -eq '0' -or $env:INTENT_ANCHOR_ENFORCE -eq '0') { exit 0 }
|
|
45
|
+
|
|
46
|
+
$obj = Read-HookStdinJson
|
|
47
|
+
if (-not $obj) { exit 0 }
|
|
48
|
+
|
|
49
|
+
$cid = Get-SafeConversationId $obj
|
|
50
|
+
$pendingDir = Get-HooksPendingDir
|
|
51
|
+
$latch = Join-Path $pendingDir "intent-injected-$cid.flag"
|
|
52
|
+
$hashFile = Join-Path $pendingDir "last-query-$cid.hash"
|
|
53
|
+
|
|
54
|
+
# Already injected this turn -> quiet. Latch cleared at every stop.
|
|
55
|
+
if (Test-Path $latch) { exit 0 }
|
|
56
|
+
|
|
57
|
+
# --- current request (best-effort; absent in sandboxed runs) -----------------
|
|
58
|
+
$currentQuery = Get-LastUserQuery $obj
|
|
59
|
+
$hasQuery = -not [string]::IsNullOrWhiteSpace($currentQuery)
|
|
60
|
+
|
|
61
|
+
$currentHash = ''
|
|
62
|
+
$promptChanged = $false
|
|
63
|
+
if ($hasQuery) {
|
|
64
|
+
$bytes = [System.Text.Encoding]::UTF8.GetBytes($currentQuery)
|
|
65
|
+
$hasher = [System.Security.Cryptography.SHA256]::Create()
|
|
66
|
+
$currentHash = -join ($hasher.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') })
|
|
67
|
+
$prevHash = ''
|
|
68
|
+
if (Test-Path $hashFile) { $prevHash = (Get-Content $hashFile -Raw -ErrorAction SilentlyContinue).Trim() }
|
|
69
|
+
$promptChanged = ($currentHash -ne $prevHash)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# --- repo root (same resolution as scope-gate-audit.ps1) ---------------------
|
|
73
|
+
$root = ''
|
|
74
|
+
$cands = @()
|
|
75
|
+
if ($obj.PSObject.Properties['cwd'] -and $obj.cwd) { $cands += [string]$obj.cwd }
|
|
76
|
+
if ($obj.PSObject.Properties['workspace_roots']) { foreach ($w in $obj.workspace_roots) { $cands += [string]$w } }
|
|
77
|
+
foreach ($c in $cands) { $f = ConvertTo-FwdPath $c; if ($f -and (Test-Path -LiteralPath $f)) { $root = $f.TrimEnd('/'); break } }
|
|
78
|
+
if (-not $root) { $root = (& { if ($env:CURSOR_PROJECT_DIR) { $env:CURSOR_PROJECT_DIR } else { $HOME } }).Replace('\', '/').TrimEnd('/') }
|
|
79
|
+
|
|
80
|
+
# --- read the existing contract (if any) -------------------------------------
|
|
81
|
+
$scopeExists = $false
|
|
82
|
+
$scopeIntent = ''
|
|
83
|
+
$scopeAcceptance = ''
|
|
84
|
+
$scopeFiles = ''
|
|
85
|
+
$scopePath = Join-Path $root '.scope.json'
|
|
86
|
+
if (Test-Path -LiteralPath $scopePath) {
|
|
87
|
+
try {
|
|
88
|
+
$sj = Get-Content -LiteralPath $scopePath -Raw | ConvertFrom-Json
|
|
89
|
+
if ($sj.intent) { $scopeIntent = [string]$sj.intent }
|
|
90
|
+
if ($sj.acceptance) { $scopeAcceptance = [string]$sj.acceptance }
|
|
91
|
+
if ($sj.files) { $scopeFiles = ($sj.files -join ', ') }
|
|
92
|
+
$scopeExists = $true
|
|
93
|
+
} catch { $scopeExists = $false } # malformed JSON -> treat as missing
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# --- deterministic scaffold (0.4.4) -----------------------------------------
|
|
97
|
+
# When the query is available and there is no valid contract, the hook writes
|
|
98
|
+
# .scope.json itself — intent from <user_query>, obvious TODO placeholders
|
|
99
|
+
# for files/acceptance. Fires on prompt change (incl. first turn: empty prev
|
|
100
|
+
# hash) or whenever the contract is still missing on a turn boundary.
|
|
101
|
+
$scaffoldWritten = $false
|
|
102
|
+
$shouldScaffold = $hasQuery -and (-not $scopeExists)
|
|
103
|
+
if ($shouldScaffold) {
|
|
104
|
+
try {
|
|
105
|
+
$scaffold = [ordered]@{
|
|
106
|
+
intent = $currentQuery
|
|
107
|
+
files = @('<TODO: list files>')
|
|
108
|
+
acceptance = '<TODO: deterministic success check>'
|
|
109
|
+
allow_growth = $false
|
|
110
|
+
}
|
|
111
|
+
$json = $scaffold | ConvertTo-Json -Depth 4 -Compress
|
|
112
|
+
$dir = Split-Path -Parent $scopePath
|
|
113
|
+
if ($dir -and -not (Test-Path -LiteralPath $dir)) {
|
|
114
|
+
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
|
115
|
+
}
|
|
116
|
+
[System.IO.File]::WriteAllText($scopePath, $json, [System.Text.UTF8Encoding]::new($false))
|
|
117
|
+
$scopeIntent = $currentQuery
|
|
118
|
+
$scopeAcceptance = '<TODO: deterministic success check>'
|
|
119
|
+
$scopeFiles = '<TODO: list files>'
|
|
120
|
+
$scopeExists = $true
|
|
121
|
+
$scaffoldWritten = $true
|
|
122
|
+
} catch { }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# --- compose the anchor message ---------------------------------------------
|
|
126
|
+
# Re-injection (req 2) is unconditional whenever a contract exists.
|
|
127
|
+
# Recompile-demand (req 1) fires when the prompt moved but a real contract exists.
|
|
128
|
+
$queryLine = if ($hasQuery) { $currentQuery } else { '(current request unavailable - no transcript in this event)' }
|
|
129
|
+
|
|
130
|
+
if ($scaffoldWritten) {
|
|
131
|
+
$msg = @"
|
|
132
|
+
INTENT ANCHOR (scaffold written to .scope.json) - contract materialized from your request.
|
|
133
|
+
|
|
134
|
+
intent: $scopeIntent
|
|
135
|
+
files: $scopeFiles
|
|
136
|
+
acceptance: $scopeAcceptance
|
|
137
|
+
|
|
138
|
+
The hook wrote this scaffold to $scopePath — intent is locked from your current
|
|
139
|
+
request. Replace the TODO placeholders with real files[] and acceptance before
|
|
140
|
+
editing source. The contract is on disk and will be re-injected every turn.
|
|
141
|
+
"@
|
|
142
|
+
} elseif (-not $scopeExists) {
|
|
143
|
+
$msg = @"
|
|
144
|
+
INTENT ANCHOR (pre-compile) - no .scope.json found in $root.
|
|
145
|
+
|
|
146
|
+
Current request:
|
|
147
|
+
$queryLine
|
|
148
|
+
|
|
149
|
+
You have NOT compiled your Anchor Set. Before editing files, write .scope.json
|
|
150
|
+
in the repo root:
|
|
151
|
+
intent: one operational sentence (what is strictly necessary)
|
|
152
|
+
files: the exact files you will touch
|
|
153
|
+
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
|
+
"@
|
|
174
|
+
} else {
|
|
175
|
+
# Same prompt continuing (or query unavailable) -> re-inject the contract.
|
|
176
|
+
$driftNote = if ($hasQuery) {
|
|
177
|
+
"Every edit this turn must advance intent and stay inside files. acceptance is the bar for done."
|
|
178
|
+
} else {
|
|
179
|
+
"(request unavailable to diff against - re-injecting the contract as-is.)"
|
|
180
|
+
}
|
|
181
|
+
$msg = @"
|
|
182
|
+
INTENT ANCHOR (re-injected this turn from .scope.json) - your contract. Do not drift from it.
|
|
183
|
+
|
|
184
|
+
intent: $scopeIntent
|
|
185
|
+
files: $scopeFiles
|
|
186
|
+
acceptance: $scopeAcceptance
|
|
187
|
+
|
|
188
|
+
$driftNote If a constraint above conflicts with what you are about to do, stop
|
|
189
|
+
and reconcile - the contract outranks momentum.
|
|
190
|
+
"@
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# --- stash to the feedback bus (drained by post-tool-use.ps1) ----------------
|
|
194
|
+
$pending = Join-Path $pendingDir "feedback-$cid.txt"
|
|
195
|
+
try {
|
|
196
|
+
New-Item -ItemType Directory -Path $pendingDir -Force | Out-Null
|
|
197
|
+
$prefix = ''
|
|
198
|
+
if ((Test-Path $pending) -and ((Get-Item $pending).Length -gt 0)) { $prefix = "`n`n---`n`n" }
|
|
199
|
+
Add-Content -Path $pending -Value ($prefix + $msg) -NoNewline
|
|
200
|
+
} catch { }
|
|
201
|
+
|
|
202
|
+
# --- arm the latch; record the query hash for next-turn change detection -----
|
|
203
|
+
New-Item -ItemType File -Path $latch -Force -ErrorAction SilentlyContinue | Out-Null
|
|
204
|
+
if ($currentHash) {
|
|
205
|
+
try { Set-Content -Path $hashFile -Value $currentHash -NoNewline -ErrorAction SilentlyContinue } catch { }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
exit 0
|
|
@@ -43,12 +43,13 @@ $cid = Get-SafeConversationId $obj
|
|
|
43
43
|
$pendingDir = Get-HooksPendingDir
|
|
44
44
|
$marker = Join-Path $pendingDir "session-edits-$cid.txt"
|
|
45
45
|
$flag = Join-Path $pendingDir "reviewed-$cid.flag"
|
|
46
|
-
$anchorFlag
|
|
46
|
+
$anchorFlag = Join-Path $pendingDir "anchor-declared-$cid.flag"
|
|
47
|
+
$intentLatch = Join-Path $pendingDir "intent-injected-$cid.flag"
|
|
47
48
|
|
|
48
|
-
# Unconditionally clear the
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
Remove-Item $anchorFlag -Force -ErrorAction SilentlyContinue
|
|
49
|
+
# Unconditionally clear the per-turn latches so the next subagent run re-fires.
|
|
50
|
+
# Clearing here (not only inside the reviewed-flag block below) can never strand
|
|
51
|
+
# them silenced. last-query-<cid>.hash is kept (cross-turn prompt-change detect).
|
|
52
|
+
Remove-Item $anchorFlag, $intentLatch -Force -ErrorAction SilentlyContinue
|
|
52
53
|
|
|
53
54
|
# One-shot brake: the previous subagentStop for this id emitted the review.
|
|
54
55
|
if (Test-Path $flag) {
|
package/windows/hooks.json
CHANGED
|
@@ -47,6 +47,11 @@
|
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
49
|
"postToolUse": [
|
|
50
|
+
{
|
|
51
|
+
"command": "pwsh.exe -NoProfile -File ~/.agents/hooks/intent-anchor.ps1",
|
|
52
|
+
"timeout": 5,
|
|
53
|
+
"_comment": "5s: THIN INTENT COMPILATION (anti Salience Dilution). Registered FIRST so it appends to the feedback bus before post-tool-use.ps1 drains it (same-tool delivery). On the FIRST tool boundary of each turn (per-turn latch intent-injected-<cid>.flag, cleared unconditionally at every stop), (1) re-injects the existing .scope.json (intent/files/acceptance) into additional_context so the contract is back in the model's attentional focus before edits pile up - UNCONDITIONAL, no transcript needed; (2) if the current <user_query> hash differs from last-query-<cid>.hash, demands the agent UPDATE .scope.json to match the new request (scope tracks the request). No .scope.json -> demand one be written. Needs transcript_path for change-detection; degrades to silent there but re-injection still runs. Advisory only; never blocks. Disable: HOOKS_ENFORCE=0 or INTENT_ANCHOR_ENFORCE=0."
|
|
54
|
+
},
|
|
50
55
|
{
|
|
51
56
|
"command": "pwsh.exe -NoProfile -File ~/.agents/hooks/post-tool-use.ps1",
|
|
52
57
|
"timeout": 5,
|