cursordoctrine 0.2.3 → 0.3.1

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 CHANGED
@@ -110,4 +110,4 @@ Also validate the config: `~/.cursor/hooks.json` must parse as JSON.
110
110
 
111
111
  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.
112
112
 
113
- Kill switches if something misbehaves: `HOOKS_ENFORCE=0` (everything advisory off), `PERM_GATE_ENFORCE=0`, `MINIMAL_EDITING_ENFORCE=0`, `ANTI_SLOP_ENFORCE=0`, `FINAL_REVIEW_ENFORCE=0`, `SUBAGENT_REVIEW_ENFORCE=0`.
113
+ Kill switches if something misbehaves: `HOOKS_ENFORCE=0` (everything advisory off), `PERM_GATE_ENFORCE=0`, `MINIMAL_EDITING_ENFORCE=0` (deprecated in 0.3.0), `SEMANTIC_DENSITY_ENFORCE=0`, `ANTI_SLOP_ENFORCE=0`, `FINAL_REVIEW_ENFORCE=0`, `SUBAGENT_REVIEW_ENFORCE=0`.
package/README.md CHANGED
@@ -6,8 +6,8 @@ Thin self-review hooks for Cursor. Five hook events, one message bus. The model
6
6
 
7
7
  A small set of Cursor hooks that make the agent review its own work without bolting a static-analysis pipeline onto every keystroke. There is no regex army and no scoring engine. The hooks do three jobs:
8
8
 
9
- 1. **Inject the doctrine** at session start, so every chat begins with the same short governing text (`doctrine.md` + `USER-RULES.md`).
10
- 2. **Hand the model its own edits back.** After each agent edit, a self-review prompt (plus minimal-edit and anti-slop advisories when they trip) is stashed and delivered on the next turn. The model reads its own diff, fixes real bugs, and stays quiet otherwise.
9
+ 1. **Inject the doctrine** at session start, so every chat begins with the same short governing text (`doctrine.md` + `USER-RULES.md` + `declared-editing.md` — the YAGNI ultra ladder that prevents over-building before a single line is written).
10
+ 2. **Hand the model its own edits back.** After each agent edit, a self-review prompt (plus minimal-edit, semantic-density, and anti-slop advisories when they trip) is stashed and delivered on the next turn. The model reads its own diff, fixes real bugs, and stays quiet otherwise.
11
11
  3. **Gate blast radius.** One permission gate denies a short, explicit list of dangerous commands (`rm -rf /`, `curl | sh`, force-push, `npm publish`, ...). Everything else is allowed.
12
12
 
13
13
  When an implementation finishes, a stop hook fires exactly one final review pass over everything that changed — then stops. The review runs across five axes, the first of which is **intent trace**: the hook extracts your last user message from the transcript and prepends it to the review so the model must trace every diff hunk back to a concrete request. Anything untraceable is a hallucinated requirement and gets reverted — this is the only detector that catches "clean code, wrong feature," which no later axis and no linter can see. Delegated work gets the same treatment: a subagent that edited files reviews its own implementation before its result returns to the parent, and its edits are folded into the parent's final review. Every bound is enforced twice: in the script and in `hooks.json`.
@@ -41,7 +41,7 @@ The two folders are functionally identical. Windows runs everything through `pws
41
41
  | Session | `sessionStart` | `inject-doctrine` reads the doctrine + user rules and emits them as `additional_context`. |
42
42
  | Every turn | `postToolUse` | Folds completed subagents' edit markers into this conversation's marker, then drains the conversation's pending feedback file into `additional_context`. One-shot, keyed by conversation id. |
43
43
  | Shell | `beforeShellExecution` | `permission-gate` checks the command against a deny list. Allow by default, deny by list, fail open. |
44
- | Edit | `afterFileEdit` + `stop` | `self-review-trigger` stashes the review prompt per edit; `minimal-edit-audit` and `anti-slop-audit` append advisories when thresholds trip (new deps / premature abstraction / redundant comments / Tier 3 operational slop: retry-without-backoff, await-in-loop, telemetry spam); `final-review` fires one end-of-implementation pass. |
44
+ | Edit | `afterFileEdit` + `stop` | `self-review-trigger` stashes the review prompt per edit; `minimal-edit-audit` (deprecated in 0.3.0), `semantic-density-audit`, and `anti-slop-audit` append advisories when thresholds trip (new deps / premature abstraction / redundant comments / **semantic opacity**: low-density identifiers like `DataManager`, `process()`, `utils.ts` / Tier 3 operational slop: retry-without-backoff, await-in-loop, telemetry spam); `final-review` fires one end-of-implementation pass. |
45
45
  | 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`. |
46
46
 
47
47
  ## Install
@@ -69,7 +69,8 @@ All hooks fail open and always exit 0. Nothing here can block your session.
69
69
  |---|---|---|
70
70
  | `HOOKS_ENFORCE=0` | on | turns off all advisory hooks at once |
71
71
  | `PERM_GATE_ENFORCE=0` | on | disables the permission gate |
72
- | `MINIMAL_EDITING_ENFORCE=0` | on | disables the over-edit advisory |
72
+ | `MINIMAL_EDITING_ENFORCE=0` | on | disables the over-edit advisory (deprecated in 0.3.0) |
73
+ | `SEMANTIC_DENSITY_ENFORCE=0` | on | disables the semantic-opacity advisory |
73
74
  | `ANTI_SLOP_ENFORCE=0` | on | disables the slop advisory |
74
75
  | `FINAL_REVIEW_ENFORCE=0` | on | disables the final review pass |
75
76
  | `SUBAGENT_REVIEW_ENFORCE=0` | on | disables the in-subagent review pass |
package/bin/cli.mjs CHANGED
@@ -40,7 +40,7 @@ const pendingDir = join(cursorDst, '.hooks-pending');
40
40
  const hooksJsonDst = join(cursorDst, 'hooks.json');
41
41
 
42
42
  const injectName = platform === 'windows' ? 'inject-doctrine.ps1' : 'inject-doctrine.sh';
43
- const doctrineFiles = [injectName, 'doctrine.md', 'USER-RULES.md'];
43
+ const doctrineFiles = [injectName, 'doctrine.md', 'USER-RULES.md', 'declared-editing.md'];
44
44
 
45
45
  function payloadHookFiles() {
46
46
  return readdirSync(join(payload, 'hooks'));
@@ -370,12 +370,13 @@ Examples
370
370
  npx cursordoctrine uninstall
371
371
 
372
372
  Kill switches (environment variables, all hooks fail open)
373
- HOOKS_ENFORCE=0 everything advisory off
374
- PERM_GATE_ENFORCE=0 permission gate off
375
- MINIMAL_EDITING_ENFORCE=0 over-edit advisory off
376
- ANTI_SLOP_ENFORCE=0 slop advisory off
377
- FINAL_REVIEW_ENFORCE=0 final review off
378
- SUBAGENT_REVIEW_ENFORCE=0 in-subagent review off
373
+ HOOKS_ENFORCE=0 everything advisory off
374
+ PERM_GATE_ENFORCE=0 permission gate off
375
+ MINIMAL_EDITING_ENFORCE=0 over-edit advisory off (deprecated in 0.3.0)
376
+ SEMANTIC_DENSITY_ENFORCE=0 semantic-opacity advisory off
377
+ ANTI_SLOP_ENFORCE=0 slop advisory off
378
+ FINAL_REVIEW_ENFORCE=0 final review off
379
+ SUBAGENT_REVIEW_ENFORCE=0 in-subagent review off
379
380
 
380
381
  Docs https://github.com/kleosr/cursordoctrine`);
381
382
  }
@@ -0,0 +1,30 @@
1
+ # Declared-editing — YAGNI ultra
2
+
3
+ ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if unsure.
4
+
5
+ Before writing any code, stop at the first rung that holds:
6
+
7
+ 1. Does this need to exist at all? (YAGNI) If no — say so, don't build it.
8
+ 2. Does the stdlib already do this? Use it.
9
+ 3. Does a native platform feature cover it? Use it.
10
+ 4. Does an already-installed dependency solve it? Use it.
11
+ 5. Can this be one line? Make it one line.
12
+ 6. Only then: write the minimum code that works.
13
+
14
+ Ultra means:
15
+
16
+ - Deletion before addition. If you can remove code to solve the problem, remove it.
17
+ - Ship the one-liner and challenge the rest of the requirement in the same breath.
18
+ - A hand-rolled abstraction is a bug farm with a hit rate. Say so.
19
+ - Question complex requests: "Do you actually need X, or does Y cover it?"
20
+
21
+ Mark intentional simplifications with a `declared:` comment naming the ceiling
22
+ and the upgrade path: `// declared: O(n^2) scan, fine <10k rows; index at 50k`.
23
+
24
+ Not lazy about: input validation at trust boundaries, error handling that
25
+ prevents data loss, security, accessibility, anything explicitly requested.
26
+ Non-trivial logic leaves ONE runnable check behind (an assert or one small
27
+ test, no framework, no fixtures). Trivial one-liners need none.
28
+
29
+ Output format when you skipped building something:
30
+ -> skipped: [X], add when [Y]
@@ -1,6 +1,14 @@
1
1
  #!/usr/bin/env bash
2
2
  # minimal-edit-audit.sh - afterFileEdit minimal-editing advisory (Cursor, Linux).
3
3
  #
4
+ # DEPRECATED in 0.3.0 (superseded by semantic-density-audit.sh + the
5
+ # declared-editing discipline; removal slated for 0.4.0). The line-count
6
+ # heuristic here is the size-based gate the cursordoctrine audit identified
7
+ # as the antipattern: it penalizes legitimate large declared changes and
8
+ # misses small quiet drifts. Retained for compatibility with existing installs;
9
+ # new installs register semantic-density-audit.sh alongside it. To opt out of
10
+ # the deprecated hook without uninstalling: MINIMAL_EDITING_ENFORCE=0.
11
+ #
4
12
  # Audits the just-edited file for over-editing:
5
13
  # * line-count - git diff --numstat thresholds (any language).
6
14
  # * token metrics - audit-metrics.py (token-Levenshtein + cognitive
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env bash
2
+ # semantic-density-audit.sh - afterFileEdit "semantic opacity" advisory (Cursor, Linux).
3
+ #
4
+ # Guards the naming layer the other audit hooks do not see. minimal-edit-audit
5
+ # watches diff SIZE; anti-slop-audit watches generated-code PATTERNS; this hook
6
+ # watches whether the identifiers the agent JUST introduced actually communicate
7
+ # intent. DataManager, process(), utils.ts, CoreEngine - names that exist but
8
+ # say nothing.
9
+ #
10
+ # Mechanism: extract ADDED lines from `git diff HEAD -- <rel>` (with the
11
+ # untracked-file fallback anti-slop-audit uses), pipe them to density_scan.py
12
+ # (a thin wrapper over the shared low_density module), read back one JSON
13
+ # object of findings, append a short advisory to the shared pending-feedback
14
+ # file. One denylist, shared with scan_slop.py's semantic_density bucket -
15
+ # zero drift between the per-edit advisory and the audit-of-record.
16
+ #
17
+ # FAIL findings (DataManager / Utils / placeholder names) always fire. WARN
18
+ # findings (defensible DDD with a domain noun - PostgresUserRepository) only
19
+ # fire when at least one FAIL is also present, so the hook stays quiet on
20
+ # legitimate code and loud on the real slop.
21
+ #
22
+ # Advisory only: never blocks, never persists state, ALWAYS exits 0.
23
+ # Disable: HOOKS_ENFORCE=0 or SEMANTIC_DENSITY_ENFORCE=0
24
+
25
+ set +e
26
+ . "$(dirname "$0")/hook-common.sh"
27
+
28
+ [ "${HOOKS_ENFORCE:-}" = "0" ] && exit 0
29
+ [ "${SEMANTIC_DENSITY_ENFORCE:-}" = "0" ] && exit 0
30
+
31
+ input="$(read_hook_stdin)"
32
+ [ -n "$input" ] || exit 0
33
+
34
+ # audit root: project from JSON (cwd, then workspace_roots), else CURSOR_PROJECT_DIR / HOME
35
+ root=""
36
+ while IFS= read -r cand; do
37
+ [ -n "$cand" ] && [ -d "$cand" ] && { root="${cand%/}"; break; }
38
+ done <<EOF
39
+ $(json_get "$input" cwd)
40
+ $(json_get_array "$input" workspace_roots)
41
+ EOF
42
+ [ -n "$root" ] || root="${CURSOR_PROJECT_DIR:-$HOME}"
43
+ root="${root%/}"
44
+
45
+ # edited file -> repo-relative path
46
+ fp=""
47
+ for k in file_path path filename absolute_path abs_path; do
48
+ fp="$(json_get "$input" "$k")"
49
+ [ -n "$fp" ] && break
50
+ done
51
+ [ -n "$fp" ] || exit 0
52
+ rel="$fp"
53
+ case "$rel" in "$root"/*) rel="${rel#"$root"/}" ;; esac
54
+ if is_cursor_config_path "$fp" || is_cursor_config_path "$rel"; then exit 0; fi
55
+
56
+ # git repo?
57
+ git -C "$root" rev-parse --git-dir >/dev/null 2>&1 || exit 0
58
+
59
+ # --- collect ADDED lines for this file (working tree vs HEAD) --------------
60
+ added="$(git -C "$root" diff HEAD -- "$rel" 2>/dev/null |
61
+ grep -E '^\+' | grep -vE '^\+\+\+' | cut -c2- | head -n 1500)"
62
+ if [ -z "$added" ]; then
63
+ # untracked / brand-new file: whole file is "added"
64
+ if ! git -C "$root" ls-files --error-unmatch -- "$rel" >/dev/null 2>&1; then
65
+ [ -f "$root/$rel" ] && added="$(head -n 1500 "$root/$rel")"
66
+ fi
67
+ fi
68
+ [ -n "$added" ] || exit 0
69
+
70
+ # --- resolve Python + run density_scan.py ---------------------------------
71
+ # Linux ships python3; fall back to python for older distros.
72
+ py=""
73
+ for c in python3 python; do
74
+ if command -v "$c" >/dev/null 2>&1; then py="$c"; break; fi
75
+ done
76
+ [ -n "$py" ] || exit 0 # no Python -> fail open, scanner unavailable
77
+
78
+ scanner="$HOME/.cursor/skills/anti-slop/scripts/density_scan.py"
79
+ [ -f "$scanner" ] || exit 0 # skill not installed -> silent
80
+
81
+ # Pipe added lines to the scanner, read JSON back.
82
+ mout="$(printf '%s\n' "$added" | "$py" "$scanner" --rel "$rel" 2>/dev/null)"
83
+ [ -n "$mout" ] || exit 0
84
+
85
+ # --- parse JSON findings with python (the hook already requires python) ----
86
+ # jq would be ideal but the installer notes python3 as the fallback; reuse it.
87
+ parse_json() {
88
+ "$py" - "$@" <<'PYEOF' 2>/dev/null
89
+ import json, sys
90
+ try:
91
+ p = json.loads(sys.stdin.read())
92
+ except Exception:
93
+ sys.exit(1)
94
+ fails = [f for f in p.get("findings", []) if f.get("severity") == "fail"]
95
+ warns = [f for f in p.get("findings", []) if f.get("severity") == "warn"]
96
+ if not fails and not warns:
97
+ sys.exit(2)
98
+ # WARNs only fire alongside a FAIL (defensible DDD stays quiet on clean code).
99
+ flagged = fails + (warns if fails else [])
100
+ lines = []
101
+ for f in (flagged)[:12]:
102
+ tag = f.get("severity", "").upper()
103
+ ln = f.get("line", 0)
104
+ where = f"line {ln}" if ln and ln > 0 else "file name"
105
+ reason = "; ".join(f.get("reasons", []))
106
+ if len(reason) > 110:
107
+ reason = reason[:107] + "..."
108
+ lines.append(f" [{tag}] {f.get('kind','?')} '{f.get('name','?')}' ({where}): {reason}")
109
+ print("\n".join(lines))
110
+ print(f"__COUNTS__{len(fails)}__{len(warns)}")
111
+ PYEOF
112
+ }
113
+
114
+ parsed="$(printf '%s' "$mout" | parse_json)"
115
+ rc=$?
116
+ [ "$rc" -eq 0 ] || exit 0 # parse failed or no findings -> silent
117
+
118
+ # Split the parsed output: findings lines + the __COUNTS__N__N sentinel.
119
+ counts_line="$(printf '%s\n' "$parsed" | grep '__COUNTS__' | tail -1)"
120
+ findings_block="$(printf '%s\n' "$parsed" | grep -v '__COUNTS__')"
121
+ fail_n="$(printf '%s' "$counts_line" | sed -E 's/.*__COUNTS__([0-9]+)__.*/\1/')"
122
+ warn_n="$(printf '%s' "$counts_line" | sed -E 's/.*__[0-9]+__([0-9]+)/\1/')"
123
+
124
+ # --- compose advisory ------------------------------------------------------
125
+ summary="Semantic-density audit - $rel - ${fail_n} FAIL, ${warn_n} WARN"
126
+
127
+ advice=' High-density names are predictable from the name alone (InvoiceEmailSender,
128
+ PostgresUserRepository, GenerateMonthlyReport). Low-density names name a
129
+ category, not a thing (Manager, Utils, process, handleThing). Rename so the
130
+ identifier states its concrete responsibility. WARNs with a domain noun are
131
+ defensible DDD and can be left if intentional.'
132
+
133
+ msg="${summary}
134
+
135
+ ${findings_block}
136
+
137
+ ${advice}
138
+
139
+ (Advisory; disable: SEMANTIC_DENSITY_ENFORCE=0)"
140
+
141
+ # --- append to the shared pending file --------------------------------------
142
+ cid="$(safe_conversation_id "$input")"
143
+ pending="$(hooks_pending_dir)/feedback-${cid}.txt"
144
+ mkdir -p "$(dirname "$pending")" 2>/dev/null
145
+ if [ -s "$pending" ]; then
146
+ printf '\n\n---\n\n%s' "$msg" >> "$pending" 2>/dev/null
147
+ else
148
+ printf '%s' "$msg" >> "$pending" 2>/dev/null
149
+ fi
150
+
151
+ exit 0
package/linux/hooks.json CHANGED
@@ -19,7 +19,13 @@
19
19
  "command": "bash ~/.agents/hooks/minimal-edit-audit.sh",
20
20
  "timeout": 15,
21
21
  "matcher": "^(Write|StrReplace|EditNotebook)$",
22
- "_comment": "15s: minimal-editing advisory on the edited file (git --numstat line-count + audit-metrics.py token metrics on .py, from ~/.cursor/skills/minimal-editing/ if installed). Appends findings to the conversation's pending file; never blocks. Disable: HOOKS_ENFORCE=0 or MINIMAL_EDITING_ENFORCE=0."
22
+ "_comment": "15s (DEPRECATED in 0.3.0, superseded by semantic-density-audit + declared-editing; retained for compat, removal slated for 0.4.0): minimal-editing advisory on the edited file (git --numstat line-count). Size-based gate; the intent-declared gate supersedes it. Appends findings to the conversation's pending file; never blocks. Disable: HOOKS_ENFORCE=0 or MINIMAL_EDITING_ENFORCE=0."
23
+ },
24
+ {
25
+ "command": "bash ~/.agents/hooks/semantic-density-audit.sh",
26
+ "timeout": 15,
27
+ "matcher": "^(Write|StrReplace|EditNotebook)$",
28
+ "_comment": "15s: semantic-opacity advisory on the edited file. Extracts added lines from git diff, pipes to density_scan.py (shared low_density module), flags identifiers that communicate no intent (DataManager, process(), utils.ts, CoreEngine). FAIL = bare low-density token or generic-suffix class without domain noun; WARN = defensible DDD with domain noun (PostgresUserRepository) - only fires alongside a FAIL so clean code stays quiet. One denylist shared with scan_slop.py's semantic_density bucket. Appends to pending; never blocks. Disable: HOOKS_ENFORCE=0 or SEMANTIC_DENSITY_ENFORCE=0."
23
29
  },
24
30
  {
25
31
  "command": "bash ~/.agents/hooks/anti-slop-audit.sh",
@@ -16,7 +16,7 @@ set +e
16
16
  cat >/dev/null
17
17
 
18
18
  context=""
19
- for p in "$HOME/.cursor/doctrine.md" "$HOME/.cursor/USER-RULES.md"; do
19
+ for p in "$HOME/.cursor/doctrine.md" "$HOME/.cursor/USER-RULES.md" "$HOME/.cursor/declared-editing.md"; do
20
20
  if [ -f "$p" ]; then
21
21
  part="$(cat "$p")"
22
22
  if [ -n "$context" ]; then context="$context
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursordoctrine",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "Thin self-review hooks for Cursor — the model is the auditor. Intent-trace final review (Tier 0), unified 13-item anti-slop checklist, operational slop detection.",
5
5
  "bin": {
6
6
  "cursordoctrine": "bin/cli.mjs"
@@ -14,7 +14,10 @@
14
14
  "windows/",
15
15
  "linux/",
16
16
  "skills/",
17
- "INSTALL.md"
17
+ "INSTALL.md",
18
+ "!**/__pycache__",
19
+ "!**/*.pyc",
20
+ "!**/*.pyo"
18
21
  ],
19
22
  "repository": {
20
23
  "type": "git",
@@ -20,7 +20,7 @@ description: >-
20
20
  duplicate utilities.
21
21
  metadata:
22
22
  layer: active-cleanup
23
- pairs-with: minimal-editing, anti-slop-hook
23
+ pairs-with: declared-editing (supersedes minimal-editing), semantic-density-audit
24
24
  ---
25
25
 
26
26
  # Anti-Slop
@@ -160,6 +160,7 @@ Walk every row. Rows tagged *(scanner)* are seeded mechanically by
160
160
  | **Duplicated logic** | new code mirrors something already in the repo | Delete the copy; call the existing function. Grep before you keep it. |
161
161
  | **Clone proliferation / DRY / Knowledge duplication** | `--all` reports the same function name in ≥2 files, or identical bodies under different names (`isRecord` / `isObject` / `isPlainObject`) | Keep ONE canonical definition; re-point imports; delete the copies. One source of truth per concept. |
162
162
  | **Utility explosion / Helper Hell / Fingerprints** | a swarm of tiny `is*` / `assert*` / `safe*` one-liners; fingerprints (`isRecord`, `safeParse`, `sleep`, `retry`, `assertNever`) | Inline single-use micro-helpers; consolidate genuinely shared ones into one module. |
163
+ | **Semantic opacity / low-density names** *(scanner)* | identifiers that exist but communicate no intent: `DataManager`, `CoreEngine`, `process()`, `handleThing`, `utils.ts`, `x1`, `tempFix`, `finalFinal`. FAIL = bare low-density token or generic-suffix class with no domain noun; WARN = defensible DDD with a domain noun (`PostgresUserRepository`). Shared denylist lives in `low_density.py` and fires identically in `scan_slop.py --all` and the per-edit `semantic-density-audit` hook. | Rename to state the concrete responsibility: `DataManager` → `InvoiceRepository` or `PersistUserSessions`; `process` → `GenerateMonthlyReport`; `utils.ts` → `invoice_totals.ts`. Leave WARNs that are intentional DDD. |
163
164
  | **Ignored conventions** | style / naming / structure / error-handling differs from the file's neighbours | Rewrite to match the surrounding code. |
164
165
  | **Accidental complexity** | indirection / generics / config a junior can't read in 30s | Flatten to the simplest form that works. |
165
166
  | **Superficial tests / Test theater** | the test asserts "it runs", mirrors the implementation, or cannot fail; literal tautologies (`expect(true).toBe(true)`, `assert True`) *(scanner)*; snapshot-everything, mocks of mocks, assertion poverty | Rewrite to assert real outcomes and the edge cases; delete tautological tests. |
@@ -268,7 +269,10 @@ Diff: {before} → {after} lines. Tests: {pass | n/a}
268
269
  | Hook checklist | `~/.agents/hooks/anti-slop.md` (13 items; per-edit + final-review axis 4) |
269
270
 
270
271
  The scanner is stdlib-only and needs Python 3.9+. Pairs with the **anti-slop
271
- audit hook** (`anti-slop-audit.ps1` / `.sh`, advisory per edit), the **stop
272
- hook** (`final-review.ps1` / `.sh`, five-axis session review incl. intent
273
- trace), and **minimal-editing** (smallest-diff). This skill is the active
274
- "delete it now" layer those only nudge toward.
272
+ audit hook** (`anti-slop-audit.ps1` / `.sh`, advisory per edit), the
273
+ **semantic-density-audit hook** (`semantic-density-audit.ps1` / `.sh`, flags
274
+ low-density identifiers per edit shares `low_density.py` with this scanner's
275
+ `semantic_density` bucket), the **stop hook** (`final-review.ps1` / `.sh`,
276
+ five-axis session review incl. intent trace), and **declared-editing**
277
+ (supersedes the deprecated `minimal-editing` size gate). This skill is the
278
+ active "delete it now" layer those only nudge toward.
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ """density_scan.py - per-edit semantic-density hook wrapper.
3
+
4
+ The afterFileEdit hook (semantic-density-audit.{ps1,sh}) extracts the ADDED
5
+ lines for the just-edited file from `git diff HEAD` and pipes them here on
6
+ stdin. This wrapper scores them with the shared low_density module and emits
7
+ one JSON object the hook can read. One job, one contract:
8
+
9
+ stdin: added lines (one per line, leading '+' already stripped)
10
+ argv: --rel <repo-relative path of the edited file>
11
+ stdout: {"rel": ..., "findings": [...], "count": N}
12
+ where each finding = {name, line, kind, severity, reasons}
13
+ exit: 0 always (advisory; the hook never blocks)
14
+
15
+ Why a separate wrapper instead of `scan_slop.py --added-json -`? scan_slop's
16
+ per-file signal loop is the right granularity for a whole-codebase audit, but
17
+ the hook needs ONE call that ingests pre-extracted added lines and returns
18
+ density findings only - no dep/abstraction/residue detection (that is
19
+ anti-slop-audit's job, already running in the same afterFileEdit slot). This
20
+ keeps the two hooks non-overlapping and the density path fast (<50ms typical).
21
+
22
+ Stdlib only; Python 3.9+. REPORTS only - never edits.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import sys
30
+ from typing import Any
31
+
32
+ # Resolve sibling low_density.py + scan_slop.py the same way low_density does.
33
+ _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
34
+ if _SCRIPT_DIR not in sys.path:
35
+ sys.path.insert(0, _SCRIPT_DIR)
36
+
37
+ import low_density # noqa: E402 (path set up above)
38
+
39
+
40
+ def main() -> int:
41
+ ap = argparse.ArgumentParser(
42
+ description="Per-edit semantic-density scorer (hook wrapper).")
43
+ ap.add_argument("--rel", required=True,
44
+ help="repo-relative path of the edited file (used for "
45
+ "language detection and filename scoring)")
46
+ ap.add_argument("--max-lines", type=int, default=2000,
47
+ help="cap on stdin lines read (runtime bound, matches the "
48
+ "hook's own 1500-line git cap with headroom)")
49
+ args = ap.parse_args()
50
+
51
+ rel = args.rel.replace("\\", "/").lstrip("/")
52
+
53
+ # Read added lines from stdin. The hook already stripped leading '+' and
54
+ # '+++' headers and applied its own cap; we apply a defensive second cap.
55
+ added: list[str] = []
56
+ try:
57
+ for i, line in enumerate(sys.stdin):
58
+ if i >= args.max_lines:
59
+ break
60
+ added.append(line.rstrip("\n"))
61
+ except (KeyboardInterrupt, IOError):
62
+ pass
63
+
64
+ findings: list[dict[str, Any]] = low_density.score_identifiers(added, rel)
65
+
66
+ payload = {
67
+ "rel": rel,
68
+ "findings": findings,
69
+ "count": len(findings),
70
+ "fail_count": sum(1 for f in findings if f.get("severity") == "fail"),
71
+ "warn_count": sum(1 for f in findings if f.get("severity") == "warn"),
72
+ }
73
+ print(json.dumps(payload))
74
+ return 0
75
+
76
+
77
+ if __name__ == "__main__":
78
+ sys.exit(main())