@alexcodeplace/slopgate 0.1.0

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/bin/slopgate ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ // Launcher: locate the native slopgate engine binary and exec it, passing argv
3
+ // through and propagating the exit code. The engine is Rust (crates/slopgate-rs);
4
+ // this thin node shim is the npm `bin` entry (cross-platform).
5
+ //
6
+ // One package ships every platform binary under vendor/<platform>-<arch>/; npm
7
+ // delivers them all and this shim picks the one matching the host.
8
+ //
9
+ // Resolution order:
10
+ // 1. $SLOPGATE_BIN (explicit override)
11
+ // 2. <pkg>/vendor/<platform>-<arch>/slopgate-rs (bundled prebuilt; npm-install path)
12
+ // 3. <pkg>/target/release/slopgate-rs (cargo build --release; dev)
13
+ import { spawnSync } from 'node:child_process';
14
+ import { existsSync } from 'node:fs';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { dirname, join } from 'node:path';
17
+
18
+ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
19
+ const binName = process.platform === 'win32' ? 'slopgate-rs.exe' : 'slopgate-rs';
20
+ const host = `${process.platform}-${process.arch}`;
21
+
22
+ const candidates = [
23
+ process.env.SLOPGATE_BIN,
24
+ join(pkgRoot, 'vendor', host, binName),
25
+ join(pkgRoot, 'target', 'release', binName),
26
+ ].filter(Boolean);
27
+
28
+ const bin = candidates.find((p) => existsSync(p));
29
+ if (!bin) {
30
+ process.stderr.write(
31
+ `slopgate: native engine binary not found for ${host}.\n` +
32
+ ` This platform may not be supported, or the package install was incomplete.\n` +
33
+ ` For development, build the engine with \`cargo build --release\`.\n` +
34
+ ` looked in: ${candidates.join(', ') || '(none)'}\n`,
35
+ );
36
+ process.exit(1);
37
+ }
38
+
39
+ const r = spawnSync(bin, process.argv.slice(2), { stdio: 'inherit' });
40
+ if (r.error) {
41
+ process.stderr.write(`slopgate: failed to exec engine (${bin}): ${r.error.message}\n`);
42
+ process.exit(1);
43
+ }
44
+ process.exit(r.status ?? 1);
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bash
2
+ # Slopgate baseline-guard — PreToolUse hook blocking agent bypass of the gate.
3
+ # Covers: Edit/Write to baseline.json/suppressions.json, Bash rm/mv on baseline*,
4
+ # Bash `slopgate baseline` in any form.
5
+ # Exit 2 = block the tool use (PreToolUse protocol). Exit 0 = allow. Always fail-open on errors.
6
+
7
+ set -euo pipefail
8
+ INPUT=$(cat)
9
+
10
+ tool_name=$(node -e "try{const j=JSON.parse(process.argv[1]);process.stdout.write(j.tool_name||'')}catch{}" "$INPUT" 2>/dev/null || echo '')
11
+ tool_input=$(node -e "try{const j=JSON.parse(process.argv[1]);process.stdout.write(JSON.stringify(j.tool_input||{}))}catch{}" "$INPUT" 2>/dev/null || echo '{}')
12
+
13
+ blocked=0
14
+ reason=''
15
+
16
+ case "$tool_name" in
17
+ Edit|Write)
18
+ file_path=$(node -e "try{const j=JSON.parse(process.argv[1]);process.stdout.write(j.file_path||'')}catch{}" "$tool_input" 2>/dev/null || echo '')
19
+ if echo "$file_path" | grep -qE '\.slopgate/(baseline|suppressions)\.json$'; then
20
+ blocked=1
21
+ reason="direct write to $file_path blocked — edit baseline/suppressions via slopgate CLI only"
22
+ fi
23
+ ;;
24
+ Bash)
25
+ cmd=$(node -e "try{const j=JSON.parse(process.argv[1]);process.stdout.write(j.command||'')}catch{}" "$tool_input" 2>/dev/null || echo '')
26
+ # Block: slopgate baseline (create or --update)
27
+ if echo "$cmd" | grep -qE '(^|[;&|[:space:]])(npx[[:space:]]+|pnpm[[:space:]]+exec[[:space:]]+|yarn[[:space:]]+)?(node[[:space:]]+[^ ]+/bin/slopgate|slopgate)[[:space:]]+baseline([[:space:]]|$)'; then
28
+ blocked=1
29
+ reason="slopgate baseline blocked — agent cannot update/create baseline; run in your own terminal"
30
+ fi
31
+ # Block: rm/mv targeting .slopgate/baseline*
32
+ if echo "$cmd" | grep -qE '(rm|mv)\s+.*\.slopgate/baseline'; then
33
+ blocked=1
34
+ reason="rm/mv of .slopgate/baseline blocked — baseline integrity guard"
35
+ fi
36
+ ;;
37
+ esac
38
+
39
+ if [ "$blocked" -eq 1 ]; then
40
+ echo "⛔ SLOPGATE GUARD: $reason" >&2
41
+ # Write bypass-attempt stats row (fail-open)
42
+ ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
43
+ node -e "
44
+ const crypto=require('crypto'),fs=require('fs'),path=require('path'),os=require('os');
45
+ const root=process.argv[1], reason=process.argv[2];
46
+ try {
47
+ const key=crypto.createHash('sha256').update(root).digest('hex').slice(0,16);
48
+ const sess=JSON.parse(fs.readFileSync(path.join(os.homedir(),'.slopgate','sessions',key+'.json'),'utf8'));
49
+ const row=JSON.stringify({ts:new Date().toISOString(),project:path.basename(root),projectPath:root,
50
+ model:sess.model||'unknown',sessionId:sess.sessionId||null,mode:'bypass-attempt',
51
+ ruleId:'baseline-tamper',severity:'critical',category:'security',engine:'bypass-attempt',
52
+ file:null,line:null,reason});
53
+ const gp=path.join(os.homedir(),'.slopgate','stats.jsonl');
54
+ fs.appendFileSync(gp,row+'\n');
55
+ } catch { /* fail-open */ }
56
+ " "$ROOT" "$reason" 2>/dev/null || true
57
+ exit 2
58
+ fi
59
+
60
+ exit 0
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bash
2
+ # Slopgate PreToolUse hook — runs --staged before a git commit. Exit 1 → commit blocked.
3
+ TOOL_JSON=$(cat)
4
+ CMD=$(node -e "
5
+ let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.command||'')}catch{process.stdout.write('')}});" <<< "$TOOL_JSON" 2>/dev/null)
6
+ echo "$CMD" | grep -qE 'git[[:space:]]+commit' || exit 0
7
+
8
+ ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
9
+ CONFIG="$ROOT/.slopgate/config.toml"
10
+ [ -f "$CONFIG" ] || exit 0
11
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
+ exec node "$HERE/../bin/slopgate" --staged --config "$CONFIG"
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ # Slopgate PostToolUse hook — single-file scan after Edit/Write.
3
+ # Exit 2 → stderr feeds back into the agent turn. FAIL-OPEN: any error/timeout → exit 0.
4
+ TOOL_JSON=$(cat)
5
+ FILE=$(node -e "
6
+ let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.file_path||'')}catch{process.stdout.write('')}});" <<< "$TOOL_JSON" 2>/dev/null) || exit 0
7
+ [ -n "$FILE" ] || exit 0
8
+ case "$FILE" in *.test.ts|*.test.tsx) exit 0 ;; *.ts|*.tsx|*.astro) ;; *) exit 0 ;; esac
9
+ # Skip fixture files — they are intentional violation examples for slopgate self-test.
10
+ case "$FILE" in */.slopgate/fixtures/*|*/slopgate/*/fixtures/*) exit 0 ;; esac
11
+
12
+ ROOT=$(git -C "$(dirname "$FILE")" rev-parse --show-toplevel 2>/dev/null) || exit 0
13
+ CONFIG="$ROOT/.slopgate/config.toml"
14
+ [ -f "$CONFIG" ] || exit 0
15
+
16
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
+ OUT=$(timeout 5 node "$HERE/../bin/slopgate" --file "$FILE" --config "$CONFIG" 2>&1)
18
+ [ "$?" -eq 1 ] && { echo "$OUT" >&2; exit 2; }
19
+ exit 0
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # Slopgate SessionStart hook — capture the session-start model for stats attribution.
3
+ # Only SessionStart receives a `model` field; mid-session /model switches are invisible.
4
+ # Fail-open: any error leaves no file -> stats resolves model to 'unknown'.
5
+ ROOT=$(realpath "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" 2>/dev/null || git rev-parse --show-toplevel 2>/dev/null || pwd)
6
+ exec node -e '
7
+ const crypto = require("crypto"), fs = require("fs"), path = require("path"), os = require("os");
8
+ const root = process.argv[1];
9
+ let d = "";
10
+ process.stdin.on("data", (c) => (d += c)).on("end", () => {
11
+ let m;
12
+ try {
13
+ const j = JSON.parse(d);
14
+ m = { model: j.model || "unknown", sessionId: j.session_id || null, startedAt: new Date().toISOString(), cwd: j.cwd || root };
15
+ } catch {
16
+ m = { model: "unknown", sessionId: null, startedAt: new Date().toISOString(), cwd: root };
17
+ }
18
+ try {
19
+ const key = crypto.createHash("sha256").update(root).digest("hex").slice(0, 16);
20
+ const dir = path.join(process.env.HOME || os.homedir(), ".slopgate", "sessions");
21
+ fs.mkdirSync(dir, { recursive: true });
22
+ fs.writeFileSync(path.join(dir, key + ".json"), JSON.stringify(m));
23
+ } catch { /* fail-open */ }
24
+ });
25
+ ' "$ROOT"
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@alexcodeplace/slopgate",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Global code-quality / anti-slop gate — engine shared, rules per-project.",
6
+ "license": "MIT",
7
+ "author": "Alex <git@alex.org.il>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/alexcodeplace/slopgate.git"
11
+ },
12
+ "homepage": "https://github.com/alexcodeplace/slopgate#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/alexcodeplace/slopgate/issues"
15
+ },
16
+ "bin": {
17
+ "slopgate": "bin/slopgate"
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "hooks",
22
+ "rules",
23
+ "skills",
24
+ "vendor",
25
+ "!rules/baseline/fixtures",
26
+ "!bin/slopgate-rs"
27
+ ],
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "scripts": {
32
+ "self-test": "node bin/slopgate --self-test --config rules/baseline/selftest.config.toml",
33
+ "test": "cargo test --workspace"
34
+ }
35
+ }
@@ -0,0 +1,7 @@
1
+ id: empty-catch-block-ts
2
+ language: typescript
3
+ severity: error
4
+ message: Empty catch block swallows error silently
5
+ note: '{"severity":"high","category":"convention","resolution":"Handle or rethrow. Minimum: log with context (console.error). Comment-only bodies count as empty."}'
6
+ rule:
7
+ pattern: 'try { $A } catch ($E) {}'
@@ -0,0 +1,7 @@
1
+ id: empty-catch-block-tsx
2
+ language: tsx
3
+ severity: error
4
+ message: Empty catch block swallows error silently
5
+ note: '{"severity":"high","category":"convention","resolution":"Handle or rethrow. Minimum: log with context (console.error). Comment-only bodies count as empty."}'
6
+ rule:
7
+ pattern: 'try { $A } catch ($E) {}'
@@ -0,0 +1,9 @@
1
+ id: inner-html-assignment
2
+ language: tsx
3
+ severity: error
4
+ message: innerHTML / insertAdjacentHTML assignment — XSS surface
5
+ note: '{"severity":"high","category":"security","resolution":"Build DOM via safe APIs or sanitize with isomorphic-dompurify."}'
6
+ rule:
7
+ any:
8
+ - pattern: $X.innerHTML = $$$
9
+ - pattern: $X.insertAdjacentHTML($$$)
@@ -0,0 +1,9 @@
1
+ id: slopgate-canary
2
+ language: tsx
3
+ severity: error
4
+ message: slopgate self-test canary
5
+ note: '{"severity":"high","category":"convention","resolution":"self-test only — must only ever match fixtures/"}'
6
+ files:
7
+ - '**/fixtures/**'
8
+ rule:
9
+ pattern: __SLOPGATE_AST_CANARY__
@@ -0,0 +1,25 @@
1
+ id: target-blank-norel
2
+ language: tsx
3
+ severity: error
4
+ message: target=_blank without rel=noopener noreferrer (reverse-tabnabbing)
5
+ note: '{"severity":"high","category":"security","resolution":"Add rel=\"noopener noreferrer\" to the element."}'
6
+ rule:
7
+ any:
8
+ - all:
9
+ - kind: jsx_opening_element
10
+ - has:
11
+ kind: jsx_attribute
12
+ regex: '^target=["\x27]_blank["\x27]$'
13
+ - not:
14
+ has:
15
+ kind: jsx_attribute
16
+ regex: '^rel='
17
+ - all:
18
+ - kind: jsx_self_closing_element
19
+ - has:
20
+ kind: jsx_attribute
21
+ regex: '^target=["\x27]_blank["\x27]$'
22
+ - not:
23
+ has:
24
+ kind: jsx_attribute
25
+ regex: '^rel='
@@ -0,0 +1,38 @@
1
+ id: window-in-render
2
+ language: tsx
3
+ severity: error
4
+ message: window/document accessed during render — SSR crash
5
+ note: '{"severity":"medium","category":"convention","resolution":"Move into useEffect/useLayoutEffect, an event handler, or guard with typeof window !== \"undefined\"."}'
6
+ files:
7
+ - '**/src/components/**'
8
+ - '**/src/features/**'
9
+ - '**/fixtures/src/components/**'
10
+ ignores:
11
+ - '**/features/design-system/**'
12
+ rule:
13
+ any:
14
+ - pattern: window.$PROP
15
+ - pattern: document.$PROP
16
+ not:
17
+ any:
18
+ - inside:
19
+ stopBy: end
20
+ pattern: useEffect($$$)
21
+ - inside:
22
+ stopBy: end
23
+ pattern: useLayoutEffect($$$)
24
+ - inside:
25
+ stopBy: end
26
+ kind: jsx_attribute
27
+ - inside:
28
+ stopBy: end
29
+ pattern: if (typeof window !== 'undefined') { $$$ }
30
+ - inside:
31
+ stopBy: end
32
+ pattern: typeof window
33
+ - inside:
34
+ stopBy: end
35
+ kind: variable_declarator
36
+ has:
37
+ field: name
38
+ regex: ^(handle|on)[A-Z]
@@ -0,0 +1,23 @@
1
+ # Canonical self-test config for the Rust engine.
2
+ # Behavior frozen against the former JS oracle via the Stage-0b golden regression test.
3
+ # Path resolution: roots are repo-root-relative; suppressions/fixtures are config-dir-relative.
4
+ # astRules is intentionally omitted; the resolver always adds
5
+ # rules/baseline/ast, and UX-high adds rules/ux/ast.
6
+ roots = ["rules/baseline/fixtures/src"]
7
+ exts = [".ts", ".tsx"]
8
+ skipDirs = ["node_modules"]
9
+ baseline = ["no-stubs", "ts-suppress", "as-any", "raw-hex", "kv-ban", "live-secrets", "eval-ban", "pii-logs", "weak-hash", "sql-safety"]
10
+ rules = []
11
+ suppressions = "./fixtures/suppressions.json"
12
+ fixtures = "./fixtures"
13
+
14
+ [ux]
15
+ a11y = "high"
16
+ cls = "high"
17
+ feedback = "high"
18
+ taste = "advisory"
19
+ advisory = "advisory"
20
+
21
+ [gate]
22
+ file = ["critical", "high"]
23
+ staged = ["critical", "high"]
@@ -0,0 +1,17 @@
1
+ id: ux-anchor-no-href
2
+ language: tsx
3
+ severity: error
4
+ message: <a onClick> without href — not a real link, not keyboard-focusable
5
+ note: '{"severity":"high","category":"convention","resolution":"Use a <button> for actions, or give the <a> a real href. An anchor with only onClick is invisible to keyboard and screen readers (ANTI-SLOP UX §11)."}'
6
+ rule:
7
+ any:
8
+ - all:
9
+ - kind: jsx_opening_element
10
+ - has: { field: name, regex: '^a$' }
11
+ - has: { kind: jsx_attribute, regex: '^onClick' }
12
+ - not: { has: { kind: jsx_attribute, regex: '^href=' } }
13
+ - all:
14
+ - kind: jsx_self_closing_element
15
+ - has: { field: name, regex: '^a$' }
16
+ - has: { kind: jsx_attribute, regex: '^onClick' }
17
+ - not: { has: { kind: jsx_attribute, regex: '^href=' } }
@@ -0,0 +1,17 @@
1
+ id: ux-async-onclick-no-disable
2
+ language: tsx
3
+ severity: error
4
+ message: async onClick on a <button> with no disabled state — double-submit / silent wait
5
+ note: '{"severity":"high","category":"convention","resolution":"Disable the trigger while the promise is in flight (disabled={pending}) and show an inline loading indicator. Prevents double-submit and ghost interactions (ANTI-SLOP UX §3/§12)."}'
6
+ rule:
7
+ any:
8
+ - all:
9
+ - kind: jsx_opening_element
10
+ - has: { field: name, regex: '^button$' }
11
+ - has: { kind: jsx_attribute, regex: 'onClick=\{\s*async' }
12
+ - not: { has: { kind: jsx_attribute, regex: '^disabled' } }
13
+ - all:
14
+ - kind: jsx_self_closing_element
15
+ - has: { field: name, regex: '^button$' }
16
+ - has: { kind: jsx_attribute, regex: 'onClick=\{\s*async' }
17
+ - not: { has: { kind: jsx_attribute, regex: '^disabled' } }
@@ -0,0 +1,11 @@
1
+ id: ux-button-no-type
2
+ language: tsx
3
+ severity: error
4
+ message: <button> without an explicit type — defaults to "submit" and can fire forms
5
+ note: '{"severity":"high","category":"convention","resolution":"Add type=\"button\" (or type=\"submit\" intentionally). A typeless button inside a form submits it on click — a classic silent bug (ANTI-SLOP UX §3/§11)."}'
6
+ rule:
7
+ any:
8
+ - all:
9
+ - kind: jsx_opening_element
10
+ - has: { field: name, regex: '^button$' }
11
+ - not: { has: { kind: jsx_attribute, regex: '^type=' } }
@@ -0,0 +1,17 @@
1
+ id: ux-div-onclick
2
+ language: tsx
3
+ severity: error
4
+ message: onClick on a <div>/<span> — not keyboard-focusable, breaks a11y
5
+ note: '{"severity":"high","category":"convention","resolution":"Use a native <button> (or add role + tabIndex + onKeyDown). Semantic HTML first — ANTI-SLOP UX §11."}'
6
+ rule:
7
+ any:
8
+ - all:
9
+ - kind: jsx_opening_element
10
+ - has: { field: name, regex: '^(div|span)$' }
11
+ - has: { kind: jsx_attribute, regex: '^onClick' }
12
+ - not: { has: { kind: jsx_attribute, regex: '^role=' } }
13
+ - all:
14
+ - kind: jsx_self_closing_element
15
+ - has: { field: name, regex: '^(div|span)$' }
16
+ - has: { kind: jsx_attribute, regex: '^onClick' }
17
+ - not: { has: { kind: jsx_attribute, regex: '^role=' } }
@@ -0,0 +1,15 @@
1
+ id: ux-img-no-alt
2
+ language: tsx
3
+ severity: error
4
+ message: <img> without an alt attribute — invisible to screen readers
5
+ note: '{"severity":"high","category":"convention","resolution":"Add alt text (alt=\"…\"), or alt=\"\" for purely decorative images (ANTI-SLOP UX §11). Semantic/accessible by default."}'
6
+ rule:
7
+ any:
8
+ - all:
9
+ - kind: jsx_self_closing_element
10
+ - has: { field: name, regex: '^img$' }
11
+ - not: { has: { kind: jsx_attribute, regex: '^alt' } }
12
+ - all:
13
+ - kind: jsx_opening_element
14
+ - has: { field: name, regex: '^img$' }
15
+ - not: { has: { kind: jsx_attribute, regex: '^alt' } }
@@ -0,0 +1,15 @@
1
+ id: ux-img-no-dimensions
2
+ language: tsx
3
+ severity: error
4
+ message: <img> without width/height — causes layout shift (CLS) as it loads
5
+ note: '{"severity":"high","category":"convention","resolution":"Reserve the footprint: add width and height attributes, or a CSS aspect-ratio / min-height (ANTI-SLOP UX §13). Use a framework <Image> that sets dimensions."}'
6
+ rule:
7
+ any:
8
+ - all:
9
+ - kind: jsx_self_closing_element
10
+ - has: { field: name, regex: '^img$' }
11
+ - not: { has: { kind: jsx_attribute, regex: '^width=' } }
12
+ - all:
13
+ - kind: jsx_opening_element
14
+ - has: { field: name, regex: '^img$' }
15
+ - not: { has: { kind: jsx_attribute, regex: '^width=' } }
@@ -0,0 +1,15 @@
1
+ id: ux-media-no-dimensions
2
+ language: tsx
3
+ severity: error
4
+ message: <video>/<iframe> without width/height — causes layout shift (CLS)
5
+ note: '{"severity":"high","category":"convention","resolution":"Reserve the footprint: set width and height (or a CSS aspect-ratio / fixed min-height) so the page does not jump as the media loads (ANTI-SLOP UX §13)."}'
6
+ rule:
7
+ any:
8
+ - all:
9
+ - kind: jsx_self_closing_element
10
+ - has: { field: name, regex: '^(video|iframe)$' }
11
+ - not: { has: { kind: jsx_attribute, regex: '^width=' } }
12
+ - all:
13
+ - kind: jsx_opening_element
14
+ - has: { field: name, regex: '^(video|iframe)$' }
15
+ - not: { has: { kind: jsx_attribute, regex: '^width=' } }
@@ -0,0 +1,15 @@
1
+ id: ux-modal-no-close
2
+ language: tsx
3
+ severity: warning
4
+ message: <Dialog>/<Modal> without an onClose/onDismiss handler — possible dead end
5
+ note: '{"severity":"medium","category":"convention","resolution":"Give every modal an explicit escape: onClose / onOpenChange / onDismiss plus a visible Close control. No dead ends (ANTI-SLOP UX §10)."}'
6
+ rule:
7
+ any:
8
+ - all:
9
+ - kind: jsx_opening_element
10
+ - has: { field: name, regex: '^(Dialog|Modal)$' }
11
+ - not: { has: { kind: jsx_attribute, regex: '^(onClose|onDismiss|onOpenChange)' } }
12
+ - all:
13
+ - kind: jsx_self_closing_element
14
+ - has: { field: name, regex: '^(Dialog|Modal)$' }
15
+ - not: { has: { kind: jsx_attribute, regex: '^(onClose|onDismiss|onOpenChange)' } }
@@ -0,0 +1,155 @@
1
+ ---
2
+ name: slopgate-improve
3
+ description: Mine a project's institutional memory (skills, agents, commands, CLAUDE.md, editor rules) for conventions not yet caught by slopgate, and add them to the right rule tier. Invoke on /slopgate-improve or when a recurring agent mistake is documented but undetected.
4
+ ---
5
+
6
+ # /slopgate-improve
7
+
8
+ **Goal:** Find real agent mistakes documented in the project's institutional memory that slopgate doesn't yet catch, and add them to the right engine tier.
9
+
10
+ Scope / focus area (optional): $ARGUMENTS — blank = all sources.
11
+
12
+ ## Rule tier model
13
+
14
+ | Tier | Pack location | Who opts in |
15
+ |------|--------------|-------------|
16
+ | **baseline** | built-in: `crates/slopgate-core/src/rules/baseline.json` (compiled into the engine) | Any project — enabled by name in TOML (`baseline = ["no-stubs", ...]`) |
17
+ | **stack** | built-in: `crates/slopgate-core/src/rules/stack.json` (compiled into the engine) | Projects using that runtime — enabled by name in TOML (`stack = ["cloudflare"]`) |
18
+ | **project** | ast-grep YAML in the `astRules` dir (`<repo>/.slopgate/rules/ast/<id>.yml`) | That repo only. NOTE: project custom **regex** packs (non-empty `rules = [...]`) are NOT yet supported by the native engine — they error (`PHASE-2: project rule packs`). Use ast-grep YAML, or defer the rule. |
19
+
20
+ When proposing a new rule, assign the lowest tier where it applies without FPs:
21
+ - Applies to any TypeScript/web project → baseline
22
+ - Applies to all projects using a specific runtime/framework → stack
23
+ - Project-specific convention or business logic → project
24
+
25
+ ## Phase 1 — Inventory current coverage
26
+
27
+ ```bash
28
+ # Baseline packs enabled (TOML array — `baseline = [...]`):
29
+ grep 'baseline' <repo>/.slopgate/config.toml
30
+ # Stack packs enabled (TOML array — `stack = [...]`):
31
+ grep 'stack' <repo>/.slopgate/config.toml
32
+ # Project ast-grep rule IDs (ids live in the .yml files under the astRules dir):
33
+ grep -rho "^id:.*" <repo>/.slopgate/rules/ast/*.yml 2>/dev/null
34
+ ```
35
+
36
+ Built-in (baseline/stack) pack IDs are engine-internal — they are not in a grep-able shipped file. The authoritative set of what is enabled = the pack names in `config.toml` plus the project ast `.yml` ids above; to see exactly what fires, run the engine with `--file` / `--self-test` (Phase 5).
37
+
38
+ Build a set of already-covered rule IDs. Do NOT propose rules with IDs already present.
39
+
40
+ ## Phase 2 — Extract rule candidates from ALL convention sources
41
+
42
+ Read `.slopgate/convention-sources.json` and open every file it lists:
43
+ - `claudeMd` roots and subtrees
44
+ - `skills/` — every SKILL.md
45
+ - `agents/` — every agent definition
46
+ - `commands/` — every command file
47
+ - `editorRules` (`.cursorrules`, `.windsurfrules`)
48
+ - `knowledgeDocs`
49
+
50
+ Also read: `git log --oneline -50` for recent commit messages that describe mistakes.
51
+
52
+ For each convention/mistake found, record:
53
+ - **source**: which file + section stated it
54
+ - **what**: the mistake or convention in one sentence
55
+ - **already covered**: yes/no (check Phase 1 set)
56
+
57
+ ## Phase 3 — Bucket triage
58
+
59
+ For each uncovered convention:
60
+
61
+ | Bucket | Description | Action |
62
+ |--------|-------------|--------|
63
+ | A — regex | Token/pattern/import detectable by line scan | Author regex rule |
64
+ | B — ast | Structural pattern (call shape, attribute presence) | Author ast-grep rule |
65
+ | C — semantic | Judgment/intent — regex/AST would FP constantly | Add to judge-rules.md only |
66
+ | skip | Too noisy, too specific, or already enforced by other tooling | Document why, discard |
67
+
68
+ **Detectable (A/B):** banned token, hardcoded value where a utility is required, required attribute absence, import boundary violation, file-shape constraint.
69
+
70
+ **NOT detectable (C/skip):** semantic conventions ("use the knowledge graph first"), runtime behavior, data-shape requirements, anything needing type information a regex can't see.
71
+
72
+ ## Phase 4A — Author regex candidates
73
+
74
+ For each bucket-A rule, draft:
75
+ ```js
76
+ {
77
+ id: '<kebab-id>',
78
+ title: '<short title>',
79
+ category: 'security|convention|duplication|boundary|api|i18n',
80
+ severity: 'critical|high|medium|low',
81
+ pattern: '<regex>',
82
+ description: '<why this is wrong>',
83
+ resolution: '<what to do instead>',
84
+ excludeGlobs: ['<legit exception globs>'],
85
+ canary: '<string the pattern must match>',
86
+ }
87
+ ```
88
+
89
+ **Confidence check:** test the pattern against 3 real examples from the codebase:
90
+ - Does it match the violation? ✓
91
+ - Does it match anything it shouldn't? If yes → add excludeGlobs or raise threshold to medium/low.
92
+ - Low confidence → defer to C (judge-only), do not ship noise.
93
+
94
+ **Tier decision:** assign to baseline / stack / project per the tier model above. NOTE: a **project-tier regex** rule cannot ship on the native engine (non-empty `rules = [...]` errors — `PHASE-2: project rule packs`). If a regex candidate is genuinely project-specific, either re-express it as an ast-grep rule (bucket B, Phase 4B) or defer it (bucket C / report as deferred). Only baseline/stack regex packs are loadable today.
95
+
96
+ ## Phase 4B — Author ast-grep candidates
97
+
98
+ For each bucket-B rule, draft a YAML rule in ast-grep syntax:
99
+ - `language: typescript|tsx`
100
+ - `severity: error|warning`
101
+ - `note:` JSON with `{"severity":"high","category":"...","resolution":"..."}`
102
+ - `rule:` structural pattern
103
+ - `files:` / `ignores:` scope guards if needed
104
+
105
+ Add to correct dir:
106
+ - Baseline-quality → `slopgate/rules/baseline/ast/<id>.yml`
107
+ - Project-specific → `<repo>/.slopgate/rules/ast/<id>.yml`
108
+
109
+ Always add a fixture file with a canary hit.
110
+
111
+ ## Phase 4C — Add judge-only candidates
112
+
113
+ For bucket-C rules: append to `<repo>/scripts/code-quality/judge-rules.md` (or equivalent):
114
+ ```
115
+ | <id> | <one-line description of what to flag> |
116
+ ```
117
+
118
+ ## Phase 5 — Verify
119
+
120
+ For each new regex rule:
121
+ ```bash
122
+ # Canary must match (approximation — the engine matches via fancy-regex for JS-RegExp parity)
123
+ node -e "console.log(new RegExp('<pattern>').test('<canary>'))"
124
+ # Run self-test
125
+ slopgate --self-test --config <repo>/.slopgate/config.toml
126
+ # Dry-run on repo (count hits; review for FPs)
127
+ slopgate --config <repo>/.slopgate/config.toml 2>&1 | grep '<id>'
128
+ ```
129
+
130
+ If FP count > 5% of hits → add excludeGlobs or demote to medium/low. If still noisy → demote to bucket C.
131
+
132
+ ## Phase 6 — Apply
133
+
134
+ - Baseline/stack rules: edit the built-in JSON in the slopgate repo (`crates/slopgate-core/src/rules/baseline.json` or `stack.json`, loaded by `crates/slopgate-core/src/rules/packs.rs`), rebuild (`cargo build --release`), commit (`feat(rules): add <id> to baseline|stack/cloudflare`). Note: the packs are compiled in via `include_str!`, so the rebuild is required for the new rule to take effect. The file is a JSON object keyed by pack name (`"no-stubs": [ {...}, ... ]`); add your rule object to the right pack's array, writing it as **JSON** — double-quoted keys and strings — matching the existing entries (the Phase 4A draft is shown as a JS literal; translate it to JSON, not the other way around).
135
+ - Project ast-grep rules: write the `.yml` to `<repo>/.slopgate/rules/ast/` (the `astRules` dir), commit to project repo. (Project regex packs are not loadable — see Phase 4A note.)
136
+ - Run self-test one final time; must exit 0.
137
+
138
+ ## Phase 7 — Report (conversation text only, no files)
139
+
140
+ ```
141
+ ## /slopgate-improve results
142
+ ### Added — regex (N): `<id>` (tier:sev) — desc
143
+ ### Added — ast (N): `<id>` (tier:sev) — desc
144
+ ### Added — judge-only (N): `<id>` — desc
145
+ ### Deferred (N): `<id>`: <why — too noisy / not statically expressible / already covered>
146
+ ### Self-test: pass|fail
147
+ ```
148
+
149
+ ## Constraints
150
+
151
+ - NEVER author a rule that fires on > ~5% false positives without excluding the FP sources.
152
+ - NEVER duplicate an existing rule ID — Phase 1 set is authoritative.
153
+ - NEVER add a baseline rule for a project-specific business concept (e.g. "agorot math").
154
+ - NEVER skip the canary check — a rule without a matching canary is untestable and will rot.
155
+ - implementer reports candidates; orchestrator + user decide on tier and enable. Do NOT self-approve critical+ rules without user confirmation.