“Given the Cursor never applies the ‘mandatory’ rules” describes two failures, and only one of them belongs to the model. The first is mechanical. A rule in .cursor/rules is read only when it is an .mdc file with frontmatter; a plain .md there is ignored. An .mdc whose frontmatter has a description but no alwaysApply: true is the type the agent includes only when it judges the rule relevant, which for a rule you call mandatory is backwards. Open each file: the first three lines must be ---, alwaysApply: true, ---, and the Rules tab under Customize shows the status of every rule. The lessons file does not load itself either: it is read when the always rule says “read docs/lessons.md before editing anything” and gives the path, because the agent does not go looking. An AGENTS.md at the repository root is read as well, and nested ones in subdirectories are combined with it, so the PowerShell rules can sit next to the PowerShell code.
The second failure is that models ignore instructions they did read, and for syntax errors I stopped treating that as a rules problem. A rule is a request; a gate is a fact. A stop hook runs when the agent believes it is finished, receives {"status": ..., "loop_count": ...} on stdin, and if it prints {"followup_message": "..."} on stdout that text is submitted as the next user message, up to loop_limit times. So .cursor/hooks.json becomes {"version": 1, "hooks": {"stop": [{"command": "pwsh -File .cursor/hooks/gate.ps1", "loop_limit": 3}]}}, and gate.ps1 walks git diff --name-only HEAD plus untracked files: for every .py it runs python -m py_compile, for every .ps1 it calls [System.Management.Automation.Language.Parser]::ParseFile($f, [ref]$null, [ref]$errs) and collects $errs, and for every text file it decodes the bytes as strict UTF-8, fails on the first byte that is not, and fails separately on a leading BOM, which strict decoding accepts. Whatever fails goes into the followup message verbatim, with file and line. Up to that limit the agent cannot declare the task done with a file that does not parse, and it fixes the error while the context that produced it is still loaded, which is the cheap moment. The same diff list answers “unauthorized and invisible changes”: any file in the diff that was not named in the task is listed under “explain or revert each of these”, and a commit before every session keeps that list exact.
Version facts belong at the top of the always rule as values, not as advice. “Use correct PowerShell syntax” is ignored because it carries no information; “pwsh 7.4 and never powershell.exe 5.1, so && is legal and -Encoding utf8 writes without BOM; Python 3.12; every file UTF-8 without BOM” gets copied because it is specific. Paste the output of $PSVersionTable.PSVersion and python --version rather than typing them from memory, and keep the rule under 500 lines, which is the documented guidance and far above what gets followed.
On the lessons file, three things made it useful instead of long. One: one lesson per entry, with the trigger in the first sentence (“when a .ps1 calls python, …”) so the agent can match an entry to the task instead of reading forty and keeping none. Two: an outcome mark. A lesson that was applied and held is worth more than a lesson written after a bad evening, and without the mark the file accumulates both at equal weight; the agent appends “applied, held” or “applied, did not hold” to the entry it used, and entries that never fire in a month move out. Three: a correction is a new dated entry next to the old one, not an edit of it, because the record of what was wrong is itself a lesson and because two contradicting entries side by side are visible in a way a silent overwrite never is. The cheap test at session start is to ask the agent to quote the three lessons it thinks apply before it touches code; if it quotes nothing, the file was not loaded and the next hour goes where your 70% went.
Disclosure: I work on a memory product, which is why I have opinions about this; nothing above depends on it.