Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .ai/skills/model-integration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 10 additions & 9 deletions .ai/skills/self-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions .cursor/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"version": 1,
"hooks": {
"beforeSubmitPrompt": [
{
"command": "python3 .cursor/hooks/decision_declaration_gate.py"
}
]
}
}
154 changes: 154 additions & 0 deletions .cursor/hooks/decision_declaration_gate.py
Original file line number Diff line number Diff line change
@@ -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/<domain>.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 -- <reason>') 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()
16 changes: 13 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -185,4 +192,7 @@ wandb

# AI agent generated symlinks
/.agents/skills
/.claude/skills
/.claude/skills

# Decision Declaration scratch draft (Cursor beforeSubmitPrompt gate) -- never ship
decision-declaration/.draft.md
23 changes: 23 additions & 0 deletions decision-declaration/.draft.md.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<!--
DECISION DECLARATION DRAFT — this is your working scratch copy, checked by the
Cursor beforeSubmitPrompt hook (decision-declaration-gate) before you can keep
working. It is NOT the final declaration you paste into the PR -- that happens at
self-review time, against the real diff, possibly with rows revised or added.

This file is gitignored on purpose (see the .gitignore entry for
decision-declaration/.draft.md) -- it should never end up in the shipped diff.

The bar here is deliberately lighter than the final PR-time check: any non-empty
row per touched domain passes, even a tentative or wrong guess. You're not
expected to have found the exact right citation yet -- you're expected to have
started forming an opinion before you're deep in implementation. self-review's
"Finalize the Decision Declaration" step is where this gets checked against the
real diff and tightened into a real citation (or a stated N/A) using the same
table format as decision-declaration/TEMPLATE.md.
-->

<!-- decision-declaration:start -->
| Domain | Decision I'm relying on (tentative is fine) |
|---|---|
| pipelines | N/A — placeholder, replace with a real guess or citation before continuing |
<!-- decision-declaration:end -->
Loading