From 94fad9c6dcb17830b6f15e9f1abf8c040eba5049 Mon Sep 17 00:00:00 2001 From: Adeel Ahmad Date: Thu, 16 Jul 2026 23:12:26 +0100 Subject: [PATCH] Add Cursor beforeSubmitPrompt gate for Decision Declaration drafts Cursor can't block a native file edit before it happens (no beforeFileEdit event; afterFileEdit is observation-only) -- confirmed against two independent sources. beforeSubmitPrompt is the earliest point Cursor can genuinely deny anything: this gate blocks the *next* prompt once a domain has uncommitted changes with no matching row in decision-declaration/.draft.md (gitignored scratch file, existence-only bar -- full citation strength stays at self-review/CI). Reuses check_decision_linkage.py's domain map and row parsing directly rather than duplicating it. Also names the concrete draft-file path in model-integration and self-review's SKILL.md, closing a gap where the prose only said "a draft" with no location. --- .ai/skills/model-integration/SKILL.md | 5 + .ai/skills/self-review/SKILL.md | 19 +-- .cursor/hooks.json | 10 ++ .cursor/hooks/decision_declaration_gate.py | 154 +++++++++++++++++++++ .gitignore | 16 ++- decision-declaration/.draft.md.example | 23 +++ 6 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 .cursor/hooks.json create mode 100644 .cursor/hooks/decision_declaration_gate.py create mode 100644 decision-declaration/.draft.md.example diff --git a/.ai/skills/model-integration/SKILL.md b/.ai/skills/model-integration/SKILL.md index e0f646a675c4..ccaa30b5651d 100644 --- a/.ai/skills/model-integration/SKILL.md +++ b/.ai/skills/model-integration/SKILL.md @@ -32,6 +32,11 @@ about before you're deep in implementation, rather than reconstructing it after fact when you open the PR. The `self-review` skill finalizes it against the real diff at the end. +Write the draft into `decision-declaration/.draft.md` (gitignored — never part of the +diff). If you're working in Cursor, a `beforeSubmitPrompt` hook checks this file and +stops you from continuing once a touched domain has no row in it — writing the draft +here isn't just good practice, it's what unblocks you. + Then work through the **Integration checklist** below ## Integration checklist diff --git a/.ai/skills/self-review/SKILL.md b/.ai/skills/self-review/SKILL.md index ac6a4d413aa8..54fe07eeeffc 100644 --- a/.ai/skills/self-review/SKILL.md +++ b/.ai/skills/self-review/SKILL.md @@ -35,15 +35,16 @@ touched, also read `.ai/models.md`, `.ai/pipelines.md`, or `.ai/modular.md`. ## 3. Finalize the Decision Declaration -If a draft exists from `model-integration`'s scaffold-time step, check it against the -**real** diff you're about to submit, not the plan you started with — plans drift -during implementation, and a stale draft is worse than an honest gap. If none exists -(this wasn't a model-integration task, or you skipped it), write one now. Either way, -by the end of this step every reference-guide domain your diff touches under -`src/diffusers/` needs a row: a real citation in your own words, or a stated reason -none of that guide's decisions apply. This becomes part of what you share in step 4 — -see the [template](../../../decision-declaration/TEMPLATE.md) for the exact format a CI -check parses. +If a draft exists from `model-integration`'s scaffold-time step (check +`decision-declaration/.draft.md`, gitignored — it's never part of the diff), check it +against the **real** diff you're about to submit, not the plan you started with — +plans drift during implementation, and a stale draft is worse than an honest gap. If +none exists (this wasn't a model-integration task, or you skipped it), write one now. +Either way, by the end of this step every reference-guide domain your diff touches +under `src/diffusers/` needs a row: a real citation in your own words, or a stated +reason none of that guide's decisions apply. This becomes part of what you share in +step 4 — see the [template](../../../decision-declaration/TEMPLATE.md) for the exact +format a CI check parses. ## 4. Report diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 000000000000..c1f2d9645707 --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "hooks": { + "beforeSubmitPrompt": [ + { + "command": "python3 .cursor/hooks/decision_declaration_gate.py" + } + ] + } +} diff --git a/.cursor/hooks/decision_declaration_gate.py b/.cursor/hooks/decision_declaration_gate.py new file mode 100644 index 000000000000..e24e273255e6 --- /dev/null +++ b/.cursor/hooks/decision_declaration_gate.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Cursor `beforeSubmitPrompt` hook: Decision Declaration draft gate. + +Cursor cannot block a native file edit before it happens (no `beforeFileEdit` +event exists; `afterFileEdit` is observation-only) -- confirmed against two +independent sources, see tech-screen/GAPS_AND_BLOCKERS.md. `beforeSubmitPrompt` is +the earliest point Cursor can genuinely deny anything relevant, so the gate fires +here instead: once a domain has changes with no declaration row, the *next* prompt +is denied until one exists, in TEMPLATE.md's format, pointed at DRAFT_FILE. + +Deliberately a lighter bar than the CI check: any non-empty row per touched domain +passes here, even a tentative or wrong guess -- matching model-integration's own +"draft, not commitment" framing. Full citation-strength verification (does the +quoted text actually resolve against .ai/.md) stays at self-review time and +at CI; duplicating that strength here would block someone from starting work on a +case they can't yet articulate precisely. + +Fails open on purpose: any unexpected error here falls back to allowing the prompt +through with a diagnostic userMessage, rather than an uncaught crash freezing the +session (Cursor's own hook-failure default is fail-open too, this just makes it +explicit and demo-legible instead of an opaque stack trace). See +GAPS_AND_BLOCKERS.md for why fail-open was chosen over failClosed for the live demo. + +Domain map and row-parsing logic are imported from check_decision_linkage.py, not +duplicated -- so this hook and the CI check can never silently drift apart. +""" +import json +import os +import subprocess +import sys + +sys.path.insert( + 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "decision-declaration") +) +from check_decision_linkage import ( # noqa: E402 + DOMAIN_PATH_PREFIXES, + extract_declaration_block, + parse_rows, +) + +DRAFT_FILE = "decision-declaration/.draft.md" + + +def compute_touched_domains(paths): + touched = set() + for path in paths: + path = path.strip() + if not path: + continue + for domain, prefixes in DOMAIN_PATH_PREFIXES.items(): + if any(path.startswith(prefix) for prefix in prefixes): + touched.add(domain) + return touched + + +def branch_reference(repo_dir): + """The ref this branch actually diverged from -- its own upstream tracking + branch, NOT a hardcoded 'origin/main'. A hardcoded main is wrong the moment a + branch is deliberately layered on top of another already-merged branch (as on + this very demo fork): diffing against main would then pick up all of that + prior, already-reviewed history as if it were newly touched right now.""" + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + cwd=repo_dir, capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + return None # no upstream (e.g. a brand new local branch) -- nothing "since branch point" to diff yet + + +def changed_paths(repo_dir): + """Everything that would appear in the eventual PR diff right now: committed + since this branch's own reference point, unioned with uncommitted changes -- + not raw `git status` alone, which would also catch unrelated stale local state + left over from earlier, unrelated work.""" + paths = set() + ref = branch_reference(repo_dir) + try: + if ref is None: + raise subprocess.CalledProcessError(1, "no-upstream") + merge_base = subprocess.run( + ["git", "merge-base", "HEAD", ref], + cwd=repo_dir, capture_output=True, text=True, check=True, timeout=5, + ).stdout.strip() + diff = subprocess.run( + ["git", "diff", "--name-only", merge_base, "HEAD"], + cwd=repo_dir, capture_output=True, text=True, check=True, timeout=5, + ).stdout + paths.update(line.strip() for line in diff.splitlines() if line.strip()) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + pass # no merge-base available -- fall through to working-tree status only + + status = subprocess.run( + ["git", "status", "--porcelain"], + cwd=repo_dir, capture_output=True, text=True, timeout=5, + ).stdout + for line in status.splitlines(): + p = line[3:].split(" -> ")[-1].strip() # porcelain: "XY path" or "XY old -> new" + if p: + paths.add(p) + return paths + + +def declared_domains(repo_dir): + try: + with open(os.path.join(repo_dir, DRAFT_FILE), encoding="utf-8") as f: + text = f.read() + except OSError: + text = "" + block = extract_declaration_block(text) + if block is None: + return set() + return {row.domain for row in parse_rows(block)} + + +def allow(message=None): + out = {"continue": True, "permission": "allow"} + if message: + out["agentMessage"] = message + print(json.dumps(out)) + + +def deny(missing): + msg = ( + f"Decision Declaration missing for: {', '.join(sorted(missing))}. " + f"Add a row to {DRAFT_FILE} for each (a real citation from the matching " + f".ai/ guide, or 'N/A -- ') before continuing." + ) + print(json.dumps({ + "continue": False, + "permission": "deny", + "userMessage": msg, + "agentMessage": msg, + })) + + +def main(): + try: + payload = json.load(sys.stdin) + repo_dir = (payload.get("workspace_roots") or [os.getcwd()])[0] + + touched = compute_touched_domains(changed_paths(repo_dir)) + missing = touched - declared_domains(repo_dir) + + if missing: + deny(missing) + else: + allow() + except Exception as exc: # noqa: BLE001 -- deliberate: fail open, not silent + allow(message=f"decision-declaration-gate error, allowing through: {exc!r}") + + +if __name__ == "__main__": + main() diff --git a/.gitignore b/.gitignore index 7b156e460abf..ee9d604c6181 100644 --- a/.gitignore +++ b/.gitignore @@ -125,8 +125,15 @@ dmypy.json .vs .vscode -# Cursor -.cursor +# Cursor (personal editor state ignored, but project-level hooks are team-shared +# config and meant to be committed -- see .cursor/hooks.json). Note: the exclusion +# pattern must be `.cursor/*`, not bare `.cursor` -- git never descends into a +# directory excluded as a unit, so negating individual files inside it would +# silently do nothing. +.cursor/* +!/.cursor/hooks.json +!/.cursor/hooks/ +!/.cursor/hooks/* # Pycharm .idea @@ -185,4 +192,7 @@ wandb # AI agent generated symlinks /.agents/skills -/.claude/skills \ No newline at end of file +/.claude/skills + +# Decision Declaration scratch draft (Cursor beforeSubmitPrompt gate) -- never ship +decision-declaration/.draft.md diff --git a/decision-declaration/.draft.md.example b/decision-declaration/.draft.md.example new file mode 100644 index 000000000000..42c0225156a8 --- /dev/null +++ b/decision-declaration/.draft.md.example @@ -0,0 +1,23 @@ + + + +| Domain | Decision I'm relying on (tentative is fine) | +|---|---| +| pipelines | N/A — placeholder, replace with a real guess or citation before continuing | +