# Slop-Gate Global Engine + Layered Rules — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use /ship (recommended) or /executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a reusable code-quality / anti-slop gate where the engine is global and auto-latest (`~/Projects/slop-gate`), rules are owned and pinned per project (`<project>/.slop-gate/`), on top of a shared baseline ruleset every project opts into.

**Architecture:** Extract and generalize multideal's `apps/web/scripts/code-quality/` engine into a standalone repo. All project coupling (paths, severity gates, enabled rule packs) moves into a per-project `config.mjs`; the engine carries only generic regex + ast-grep + suppression machinery plus global baseline rule packs. Projects wire the global hooks into their own `.claude/settings.json`.

**Tech Stack:** Node ESM (`.mjs`), ripgrep-style regex over files, `@ast-grep/cli` (optional, graceful-degrade), bash Claude-Code hooks.

**Scope (v1) / Deferred:** This plan delivers the GATE — `--file` (PostToolUse), `--staged` (PreToolUse), `--self-test`, config resolution, baseline + project rule layering, `slop-gate init`, hooks, and re-points multideal's *gate* with golden-file parity. **Deferred to a later plan:** multideal's full multi-phase audit report (jscpd, canonical-redef, api-audit, complexity, git-churn, i18n parity, markdown generation) — multideal keeps running its existing `scan.mjs` for `scan:quality` until then. The `/zc-slopgate` LLM-judge audit phase is also deferred (mirror multideal's `/md-slopgate` later). Reference: `docs/specs/2026-06-10-slop-gate-global-architecture-design.md`.

**Source-of-truth references (multideal, for ports):**
- `~/Projects/multideal/apps/web/scripts/code-quality/suppressions.mjs` (port near-verbatim)
- `~/Projects/multideal/apps/web/scripts/code-quality/ast-engine.mjs` (port + generalize)
- `scan.mjs` functions: `listSourceFiles` (59-106), `searchPattern` (121-142), `scoreFinding` (156-165), `runPatternScan` (169-236), `collectRegexViolations` (898-923), `printGateReport` (925-943), `runGate` (1016-1043), `runSelfTest` (1051-1091).

---

## Commit / git notes

- The `~/Projects/slop-gate` repo is a NEW standalone git repo — normal commits there.
- In **zync.is** (Task 15): commit only the runtime gate config — `.slop-gate/**` and `.claude/settings.json`. Do NOT `git add` this plan or the design spec (global rule: "Git = runtime only"); they stay in the working tree. `.slop-gate/` IS committed because the pinned-rules design requires rules to live in project git — this is config, not throwaway tooling. If the project's allow-list rejects `.slop-gate/`, STOP and ask the user before forcing it.

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | T1 | `~/Projects/slop-gate/{package.json,bin/slop-gate,.gitignore}` | single task |
| 2 | T2, T3, T4, T5, T6 | `src/suppressions.mjs`, `src/ast-engine.mjs`, `src/enumerate.mjs`, `src/init.mjs`, `rules/baseline/**` | ✅ disjoint files |
| 3 | T7, T8, T9 | `src/config.mjs`, `src/regex-engine.mjs`, `src/report.mjs` | ✅ disjoint files |
| 4 | T10, T11 | `src/gate.mjs`, `src/selftest.mjs` | ✅ disjoint files |
| 5 | T12 | `src/cli.mjs` | single task |
| 6 | T13 | `hooks/commit-hook.sh`, `hooks/edit-hook.sh` | single task |
| 7 | T14 | `multideal/.slop-gate/**` | single task (separate repo) |
| 8 | T15 | `zync.is/.slop-gate/**`, `zync.is/.claude/settings.json` | single task |

---

## Shared contracts (every task must match these signatures)

**Pattern object** (regex rule; baseline packs and project packs use identical shape):
```js
/** @typedef {'critical'|'high'|'medium'|'low'} Severity */
/** @typedef {{ id:string, title:string, category:string, severity:Severity,
 *   pattern:string, description:string, resolution:string,
 *   excludeGlobs?:string[], minFiles?:number, canonicalImport?:string,
 *   userVisible?:boolean, canary?:string }} Pattern */
```

**Violation object** (engine-internal, fed to suppressions + report):
```js
/** @typedef {{ id:string, severity:Severity, category?:string, file:string,
 *   line:number, lineHash:string, text:string, resolution:string,
 *   engine?:'regex'|'ast' }} Violation */
```

**ResolvedConfig** (returned by `resolveConfig`, consumed everywhere):
```js
/** @typedef {{
 *   repoRoot:string, configDir:string,
 *   roots:string[], rootsRel:string[],
 *   exts:Set<string>, skipDirs:Set<string>,
 *   patterns:Pattern[], astRuleDirs:string[],
 *   gate:{ file:Severity[], staged:Severity[] },
 *   suppressionsPath:string, fixturesDirs:string[]
 * }} ResolvedConfig */
```

**Module exports (locked names — used across tasks):**
- `suppressions.mjs`: `lineHash(line)`, `loadSuppressions(path)`, `isSuppressed(entries, v)`, `pruneStale(repoRoot, path)`
- `ast-engine.mjs`: `runAstGrepScan(config, files)`
- `enumerate.mjs`: `listSourceFiles(config, opts)` where `opts = { staged?:bool, file?:string }`
- `config.mjs`: `resolveConfig(configPath)`
- `regex-engine.mjs`: `runPatternScan(config, opts)`, `collectRegexViolations(config, findings)`
- `report.mjs`: `printGateReport(violations, mode)`
- `gate.mjs`: `runGate(mode, config)` → `{ violations, code }`
- `selftest.mjs`: `runSelfTest(config)` → `code:number`
- `init.mjs`: `runInit(targetDir)` → `code:number`
- `rules/baseline/index.mjs`: `BASELINE_PACKS` (`Record<string, Pattern[]>`), `BASELINE_AST_DIR`, `BASELINE_FIXTURES_DIR`

---

### Task 1: Initialize global slop-gate repo

**Wave:** 1
**Blocks:** T2–T13
**Blocked by:** —

**Files:**
- Create: `/home/user/Projects/slop-gate/package.json`
- Create: `/home/user/Projects/slop-gate/bin/slop-gate`
- Create: `/home/user/Projects/slop-gate/.gitignore`

- [ ] **Step 1: Create repo dirs**

Run:
```bash
mkdir -p /home/user/Projects/slop-gate/{bin,src,rules/baseline/ast,rules/baseline/fixtures/src,hooks}
cd /home/user/Projects/slop-gate && git init
```
Expected: `Initialized empty Git repository`.

- [ ] **Step 2: Write `package.json`**

```json
{
  "name": "slop-gate",
  "version": "1.0.0",
  "type": "module",
  "description": "Global code-quality / anti-slop gate — engine shared, rules per-project.",
  "bin": { "slop-gate": "bin/slop-gate" },
  "scripts": {
    "self-test": "node bin/slop-gate --self-test --config rules/baseline/selftest.config.mjs"
  }
}
```

- [ ] **Step 3: Write `bin/slop-gate`**

```js
#!/usr/bin/env node
import('../src/cli.mjs').catch((e) => {
  process.stderr.write(`slop-gate: ${e?.stack || e}\n`);
  process.exit(1);
});
```

- [ ] **Step 4: Write `.gitignore`**

```
node_modules/
*.log
.DS_Store
```

- [ ] **Step 5: Make bin executable + commit**

```bash
cd /home/user/Projects/slop-gate
chmod +x bin/slop-gate
git add package.json bin/slop-gate .gitignore
git commit -m "chore: scaffold global slop-gate repo"
```
Expected: one commit created.

---

### Task 2: Port suppressions module (generalized)

**Wave:** 2
**Blocks:** T10, T11
**Blocked by:** T1

**Files:**
- Create: `/home/user/Projects/slop-gate/src/suppressions.mjs`

Port from multideal `suppressions.mjs` with ONE change: drop the hardcoded `SUPPRESSIONS_PATH` derived from `__dirname`; callers always pass an explicit path (the project's `suppressionsPath`). `pruneStale` already takes `webRoot` — rename param to `repoRoot` for clarity.

- [ ] **Step 1: Write `src/suppressions.mjs`**

```js
/**
 * False-positive suppression registry.
 * Match key = (id, file, sha1-of-trimmed-line). Content hash survives line drift;
 * a file move invalidates the entry (deliberate: forces re-review).
 * Malformed JSON → treated as empty with error surfaced (fail toward blocking).
 */
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';

export function lineHash(line) {
  return createHash('sha1').update(String(line).trim()).digest('hex');
}

export function loadSuppressions(path) {
  if (!path || !existsSync(path)) return { entries: [], error: null };
  try {
    const j = JSON.parse(readFileSync(path, 'utf8'));
    if (!Array.isArray(j.entries)) throw new Error('"entries" is not an array');
    return { entries: j.entries, error: null };
  } catch (err) {
    return { entries: [], error: String(err) };
  }
}

/** violation must carry { id, file, lineHash } */
export function isSuppressed(entries, v) {
  return entries.some((e) => e.id === v.id && e.file === v.file && e.lineHash === v.lineHash);
}

/**
 * Audit-time stale prune: entry whose (file missing) or (no line in file hashes to lineHash)
 * is removed. Writes file only when something was pruned.
 * @param {string} repoRoot absolute repo root that `entry.file` is relative to
 * @param {string} path absolute path to suppressions.json
 */
export function pruneStale(repoRoot, path) {
  const { entries, error } = loadSuppressions(path);
  if (error) return { pruned: [], kept: entries, error };
  const kept = [];
  const pruned = [];
  for (const e of entries) {
    const abs = join(repoRoot, e.file);
    if (!existsSync(abs)) { pruned.push(e); continue; }
    const lines = readFileSync(abs, 'utf8').split('\n');
    if (lines.some((l) => lineHash(l) === e.lineHash)) kept.push(e);
    else pruned.push(e);
  }
  if (pruned.length) writeFileSync(path, JSON.stringify({ version: 1, entries: kept }, null, 2) + '\n');
  return { pruned, kept, error: null };
}
```

- [ ] **Step 2: Smoke-test exports**

Run:
```bash
cd /home/user/Projects/slop-gate
node -e "import('./src/suppressions.mjs').then(m=>{console.log(m.lineHash('  x '), m.isSuppressed([], {id:'a',file:'b',lineHash:'c'}))})"
```
Expected: a 40-char sha1 hash followed by `false`.

- [ ] **Step 3: Commit**

```bash
git add src/suppressions.mjs
git commit -m "feat: port suppressions module (path-parameterized)"
```

---

### Task 3: Port + generalize ast-grep engine

**Wave:** 2
**Blocks:** T10, T11
**Blocked by:** T1

**Files:**
- Create: `/home/user/Projects/slop-gate/src/ast-engine.mjs`

Generalize from multideal `ast-engine.mjs`: remove hardcoded `WEB_ROOT`/`SG_CONFIG`. The engine now takes a `config` and synthesizes a temporary `sgconfig.yml` listing every `config.astRuleDirs`, runs `ast-grep` with `cwd = config.repoRoot`, and targets either explicit files (repo-relative) or `config.rootsRel`.

- [ ] **Step 1: Write `src/ast-engine.mjs`**

```js
/**
 * ast-grep engine wrapper (bucket-B structural rules).
 * Returns findings in the shared violation shape.
 * Graceful degradation: missing binary → { available:false } — caller warns, never bricks.
 */
import { spawnSync } from 'node:child_process';
import { existsSync, writeFileSync, mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

export function resolveAstGrepBin(repoRoot) {
  const local = join(repoRoot, 'node_modules/.bin/ast-grep');
  if (existsSync(local)) return local;
  const probe = spawnSync('ast-grep', ['--version'], { encoding: 'utf8' });
  if (probe.status === 0) return 'ast-grep';
  return null;
}

/**
 * @param {import('./config.mjs').ResolvedConfig} config
 * @param {string[]|null} files - repo-relative targets (ts/tsx only), or null = scan config roots
 * @returns {{ available:boolean, violations:any[], errors:string[] }}
 */
export function runAstGrepScan(config, files = null) {
  const ruleDirs = (config.astRuleDirs || []).filter(existsSync);
  if (ruleDirs.length === 0) return { available: true, violations: [], errors: [] };

  const bin = resolveAstGrepBin(config.repoRoot);
  if (!bin) {
    return { available: false, violations: [], errors: ['ast-grep binary not found (npm i -g @ast-grep/cli) — bucket-B rules SKIPPED'] };
  }

  // ast-grep reads ruleDirs from an sgconfig.yml; synthesize one pointing at all rule dirs.
  const dir = mkdtempSync(join(tmpdir(), 'slopgate-sg-'));
  const sgConfig = join(dir, 'sgconfig.yml');
  writeFileSync(sgConfig, 'ruleDirs:\n' + ruleDirs.map((d) => `  - ${d}`).join('\n') + '\n');

  const targets = files === null ? config.rootsRel : files.filter((f) => /\.(ts|tsx)$/.test(f));
  if (files !== null && targets.length === 0) return { available: true, violations: [], errors: [] };

  const res = spawnSync(bin, ['scan', '--config', sgConfig, '--json', ...targets], {
    encoding: 'utf8', cwd: config.repoRoot, maxBuffer: 32 * 1024 * 1024, timeout: 60_000,
  });
  if (res.error || res.stdout == null) {
    return { available: false, violations: [], errors: [`ast-grep failed: ${res.error || res.stderr?.slice(0, 300)}`] };
  }
  let matches;
  try { matches = JSON.parse(res.stdout); } catch (e) {
    return { available: true, violations: [], errors: [`ast-grep JSON parse error: ${e}`] };
  }
  const violations = [];
  const errors = [];
  if (res.stderr && /error/i.test(res.stderr) && !/error\(s\) found in code/i.test(res.stderr)) {
    errors.push(`ast-grep stderr: ${res.stderr.slice(0, 500)}`);
  }
  for (const m of matches) {
    let meta = {};
    try { meta = JSON.parse(m.note || '{}'); } catch { errors.push(`rule ${m.ruleId}: note is not valid JSON`); }
    const firstLine = (m.lines || '').split('\n')[0];
    violations.push({
      id: m.ruleId,
      severity: meta.severity || (m.severity === 'error' ? 'high' : 'medium'),
      category: meta.category || 'convention',
      file: m.file,
      line: (m.range?.start?.line ?? 0) + 1,
      fullLine: firstLine,
      text: firstLine.trim().slice(0, 90),
      resolution: meta.resolution || m.message || '',
      engine: 'ast',
    });
  }
  return { available: true, violations, errors };
}
```

- [ ] **Step 2: Smoke-test graceful-degrade path**

Run:
```bash
cd /home/user/Projects/slop-gate
node -e "import('./src/ast-engine.mjs').then(m=>{const r=m.runAstGrepScan({repoRoot:process.cwd(),rootsRel:['src'],astRuleDirs:[]});console.log(JSON.stringify(r))})"
```
Expected: `{"available":true,"violations":[],"errors":[]}` (empty astRuleDirs → no-op, never throws).

- [ ] **Step 3: Commit**

```bash
git add src/ast-engine.mjs
git commit -m "feat: generalize ast-grep engine (config-driven ruleDirs)"
```

---

### Task 4: File enumeration module

**Wave:** 2
**Blocks:** T9, T10, T11
**Blocked by:** T1

**Files:**
- Create: `/home/user/Projects/slop-gate/src/enumerate.mjs`

Generalize multideal `listSourceFiles` (scan.mjs:59-106): walk `config.roots` instead of one `SRC_ROOT`; staged via `git diff --cached` filtered to `config.rootsRel`; file-mode validates the path is under a root with an allowed ext. All returned paths are **repo-relative**.

- [ ] **Step 1: Write `src/enumerate.mjs`**

```js
import { readdirSync, existsSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { join, relative, extname } from 'node:path';

function isTestFile(p) { return /\.test\.(ts|tsx)$/.test(p); }

/**
 * @param {import('./config.mjs').ResolvedConfig} config
 * @param {{ staged?:boolean, file?:string }} [opts]
 * @returns {string[]} repo-relative source paths
 */
export function listSourceFiles(config, opts = {}) {
  if (opts.file) {
    const rel = opts.file.startsWith('/') ? relative(config.repoRoot, opts.file) : opts.file;
    const underRoot = config.rootsRel.some((r) => rel === r || rel.startsWith(r + '/'));
    const ok = underRoot && config.exts.has(extname(rel)) && !isTestFile(rel) && existsSync(join(config.repoRoot, rel));
    return ok ? [rel] : [];
  }

  if (opts.staged) {
    try {
      const raw = execSync('git diff --cached --name-only', { encoding: 'utf8', cwd: config.repoRoot });
      return raw.trim().split('\n').filter(Boolean).filter((f) => {
        const underRoot = config.rootsRel.some((r) => f === r || f.startsWith(r + '/'));
        return underRoot && config.exts.has(extname(f)) && !isTestFile(f);
      });
    } catch { return []; }
  }

  const files = [];
  const walk = (dir) => {
    if (!existsSync(dir)) return;
    for (const ent of readdirSync(dir, { withFileTypes: true })) {
      if (config.skipDirs.has(ent.name)) continue;
      const p = join(dir, ent.name);
      if (ent.isDirectory()) walk(p);
      else if (config.exts.has(extname(ent.name)) && !isTestFile(ent.name)) files.push(relative(config.repoRoot, p));
    }
  };
  for (const root of config.roots) walk(root);
  return files.sort();
}
```

- [ ] **Step 2: Smoke-test full walk against own repo**

Run:
```bash
cd /home/user/Projects/slop-gate
node -e "import('./src/enumerate.mjs').then(m=>{const c={repoRoot:process.cwd(),roots:[process.cwd()+'/src'],rootsRel:['src'],exts:new Set(['.mjs']),skipDirs:new Set(['node_modules'])};console.log(m.listSourceFiles(c).join(','))})"
```
Expected: comma-list including `src/ast-engine.mjs`, `src/enumerate.mjs`, `src/suppressions.mjs`.

- [ ] **Step 3: Commit**

```bash
git add src/enumerate.mjs
git commit -m "feat: config-driven file enumeration (full/staged/file modes)"
```

---

### Task 5: `slop-gate init` scaffolder

**Wave:** 2
**Blocks:** T12
**Blocked by:** T1

**Files:**
- Create: `/home/user/Projects/slop-gate/src/init.mjs`

Scaffolds a project's `.slop-gate/` and prints the hook snippet. No dependency on other engine modules.

- [ ] **Step 1: Write `src/init.mjs`**

```js
import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';

const CONFIG_TEMPLATE = `// slop-gate project config. Engine is global+auto-latest; THIS file is pinned per project.
export default {
  roots: ['src'],                 // repo-relative source dirs to scan
  exts: ['.ts', '.tsx', '.astro'],
  skipDirs: ['node_modules', 'dist', 'tests', '.worktrees'],

  // baseline packs this project OPTS INTO (nothing fires until listed)
  baseline: ['no-stubs', 'ts-suppress', 'as-any'],

  // project-owned rule packs (pinned, in this repo)
  rules: [],                      // e.g. ['./rules/my-rule.mjs']
  astRules: './rules/ast',        // dir of *.yml (optional)

  gate: { file: ['critical', 'high'], staged: ['critical', 'high'] },
  suppressions: './suppressions.json',
  fixtures: './fixtures',
};
`;

const HOOK_SNIPPET = `Add to this project's .claude/settings.json:
{
  "hooks": {
    "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "/home/user/Projects/slop-gate/hooks/commit-hook.sh" }] }],
    "PostToolUse": [{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "/home/user/Projects/slop-gate/hooks/edit-hook.sh" }] }]
  }
}`;

export function runInit(targetDir) {
  const base = join(targetDir, '.slop-gate');
  if (existsSync(join(base, 'config.mjs'))) {
    process.stderr.write(`slop-gate: ${base}/config.mjs already exists — not overwriting\n`);
    return 1;
  }
  mkdirSync(join(base, 'rules/ast'), { recursive: true });
  mkdirSync(join(base, 'fixtures/src'), { recursive: true });
  writeFileSync(join(base, 'config.mjs'), CONFIG_TEMPLATE);
  writeFileSync(join(base, 'suppressions.json'), JSON.stringify({ version: 1, entries: [] }, null, 2) + '\n');
  process.stdout.write(`slop-gate: scaffolded ${base}/\n\n${HOOK_SNIPPET}\n`);
  return 0;
}
```

- [ ] **Step 2: Smoke-test into a temp dir**

Run:
```bash
cd /home/user/Projects/slop-gate
T=$(mktemp -d); node -e "import('./src/init.mjs').then(m=>process.exit(m.runInit('$T')))" && ls $T/.slop-gate && cat $T/.slop-gate/suppressions.json
```
Expected: prints scaffold notice + hook snippet; lists `config.mjs rules fixtures suppressions.json`; suppressions shows `{"version":1,"entries":[]}`.

- [ ] **Step 3: Commit**

```bash
git add src/init.mjs
git commit -m "feat: slop-gate init scaffolder"
```

---

### Task 6: Baseline rule packs

**Wave:** 2
**Blocks:** T7 (config imports baseline index), T11
**Blocked by:** T1

**Files:**
- Create: `/home/user/Projects/slop-gate/rules/baseline/index.mjs`
- Create: `/home/user/Projects/slop-gate/rules/baseline/ast/inner-html.yml`
- Create: `/home/user/Projects/slop-gate/rules/baseline/ast/slopgate-canary.yml`
- Create: `/home/user/Projects/slop-gate/rules/baseline/fixtures/src/canary.tsx`
- Create: `/home/user/Projects/slop-gate/rules/baseline/selftest.config.mjs`

Baseline packs are plain `Pattern[]` arrays (the design's "rules as data, not a plugin system"). Each pattern carries a `canary` so `--self-test` proves it still fires. Sourced from global CLAUDE.md universals.

- [ ] **Step 1: Write `rules/baseline/index.mjs`**

```js
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
export const BASELINE_AST_DIR = join(__dirname, 'ast');
export const BASELINE_FIXTURES_DIR = join(__dirname, 'fixtures');

/** @type {Record<string, import('../../src/config.mjs').Pattern[]>} */
export const BASELINE_PACKS = {
  'no-stubs': [{
    id: 'no-stubs-placeholder', title: 'Stub / placeholder / not-implemented marker',
    category: 'convention', severity: 'critical',
    pattern: '(?i)(for now|in a real app|placeholder|TODO: ?implement|not implemented)',
    description: 'Stub or deferred-work marker — global rule forbids stubs/placeholders/workarounds.',
    resolution: 'Implement the real behavior now; remove the placeholder.',
    canary: '// TODO: implement later, placeholder for now',
  }],
  'ts-suppress': [{
    id: 'ts-suppress-added', title: 'Type/lint suppression directive',
    category: 'convention', severity: 'high',
    pattern: '@ts-ignore|@ts-expect-error|eslint-disable',
    description: 'Suppressing the type checker / linter instead of fixing the cause.',
    resolution: 'Fix the underlying type/lint error; remove the suppression.',
    canary: '// @ts-ignore',
  }],
  'as-any': [{
    id: 'as-any-cast', title: '`as any` cast',
    category: 'convention', severity: 'high',
    pattern: 'as any\\b',
    description: 'Escape-hatch cast that disables type safety.',
    resolution: 'Use a precise type or a discriminated narrowing.',
    canary: 'const x = foo as any;',
  }],
  'raw-hex': [{
    id: 'raw-hex-color', title: 'Hardcoded hex color',
    category: 'convention', severity: 'high',
    pattern: '#[0-9a-fA-F]{3,8}\\b',
    description: 'Raw hex color in source instead of a design token.',
    resolution: 'Use a CSS custom property / design token.',
    excludeGlobs: ['**/tokens.css', '**/tokens/**'],
    canary: 'color: #ff0044;',
  }],
  'kv-ban': [{
    id: 'kv-binding-usage', title: 'Cloudflare KV usage',
    category: 'boundary', severity: 'critical',
    pattern: 'env\\.KV\\b|KV_NAMESPACE|\\.kv\\.',
    description: 'KV is eventually-consistent; banned for stateful/read-after-write paths (global preference).',
    resolution: 'Use a Durable Object (strong consistency) or cache.default (read caching).',
    canary: 'await env.KV.put(k, v);',
  }],
};
```

- [ ] **Step 2: Write `rules/baseline/ast/inner-html.yml`**

```yaml
id: inner-html-assignment
language: tsx
severity: error
message: innerHTML / insertAdjacentHTML assignment — XSS surface
note: '{"severity":"high","category":"security","resolution":"Build DOM via safe APIs or sanitize with isomorphic-dompurify."}'
rule:
  any:
    - pattern: $X.innerHTML = $$$
    - pattern: $X.insertAdjacentHTML($$$)
```

- [ ] **Step 3: Write `rules/baseline/ast/slopgate-canary.yml`** (self-test only — must only match fixtures)

```yaml
id: slopgate-canary
language: tsx
severity: error
message: slopgate self-test canary
note: '{"severity":"high","category":"convention","resolution":"self-test only — must only ever match fixtures/"}'
files:
  - '**/fixtures/**'
rule:
  pattern: __SLOPGATE_AST_CANARY__
```

- [ ] **Step 4: Write `rules/baseline/fixtures/src/canary.tsx`**

```tsx
// Fixture for slop-gate self-test. Contains deliberate canary tokens.
export const x = __SLOPGATE_AST_CANARY__;
function bad(el: HTMLElement) { el.innerHTML = '<b>hi</b>'; }
```

- [ ] **Step 5: Write `rules/baseline/selftest.config.mjs`** (config used by `npm run self-test`)

```js
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
export default {
  roots: ['rules/baseline/fixtures/src'],
  exts: ['.ts', '.tsx'],
  skipDirs: ['node_modules'],
  baseline: ['no-stubs', 'ts-suppress', 'as-any', 'raw-hex', 'kv-ban'],
  rules: [],
  astRules: join(__dirname, 'ast'),
  gate: { file: ['critical', 'high'], staged: ['critical', 'high'] },
  suppressions: join(__dirname, 'fixtures', 'suppressions.json'),
  fixtures: join(__dirname, 'fixtures'),
};
```

- [ ] **Step 6: Commit**

```bash
git add rules/baseline
git commit -m "feat: baseline rule packs + self-test fixtures"
```

---

### Task 7: Config resolver

**Wave:** 3
**Blocks:** T9, T10, T11, T12
**Blocked by:** T6

**Files:**
- Create: `/home/user/Projects/slop-gate/src/config.mjs`

Loads a project `config.mjs`, resolves baseline pack names → `BASELINE_PACKS`, loads project rule packs, computes absolute paths, finds repo root (git root above the config dir, else config's parent). Fails loudly on unknown baseline name or malformed rule.

- [ ] **Step 1: Write `src/config.mjs`**

```js
import { existsSync, statSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { BASELINE_PACKS, BASELINE_AST_DIR, BASELINE_FIXTURES_DIR } from '../rules/baseline/index.mjs';

/** @typedef {import('../rules/baseline/index.mjs')} _b */

function gitRoot(fromDir) {
  try { return execSync('git rev-parse --show-toplevel', { cwd: fromDir, encoding: 'utf8' }).trim(); }
  catch { return null; }
}

function validatePattern(p, src) {
  for (const k of ['id', 'severity', 'pattern', 'resolution']) {
    if (!p[k]) throw new Error(`slop-gate: rule from ${src} missing "${k}" (id=${p.id ?? '?'})`);
  }
  try { new RegExp(p.pattern); } catch (e) { throw new Error(`slop-gate: rule ${p.id} bad regex: ${e}`); }
  return p;
}

export async function resolveConfig(configPath) {
  const absConfig = isAbsolute(configPath) ? configPath : resolve(process.cwd(), configPath);
  if (!existsSync(absConfig)) throw new Error(`slop-gate: config not found: ${absConfig}`);
  const configDir = dirname(absConfig);
  const repoRoot = gitRoot(configDir) || dirname(configDir);
  const raw = (await import(pathToFileURL(absConfig).href)).default;

  // baseline packs (opt-in by name)
  const patterns = [];
  for (const name of raw.baseline ?? []) {
    if (!BASELINE_PACKS[name]) throw new Error(`slop-gate: unknown baseline pack "${name}" (known: ${Object.keys(BASELINE_PACKS).join(', ')})`);
    for (const p of BASELINE_PACKS[name]) patterns.push(validatePattern(p, `baseline:${name}`));
  }
  // project rule packs
  for (const relPath of raw.rules ?? []) {
    const abs = isAbsolute(relPath) ? relPath : resolve(configDir, relPath);
    const mod = (await import(pathToFileURL(abs).href)).default;
    if (!Array.isArray(mod)) throw new Error(`slop-gate: rule pack ${relPath} must default-export an array`);
    for (const p of mod) patterns.push(validatePattern(p, relPath));
  }

  // ast rule dirs: baseline ast + project ast (if present)
  const astRuleDirs = [BASELINE_AST_DIR];
  if (raw.astRules) {
    const abs = isAbsolute(raw.astRules) ? raw.astRules : resolve(configDir, raw.astRules);
    if (existsSync(abs) && statSync(abs).isDirectory()) astRuleDirs.push(abs);
  }

  const rootsRel = (raw.roots ?? ['src']);
  return {
    repoRoot, configDir,
    roots: rootsRel.map((r) => join(repoRoot, r)),
    rootsRel,
    exts: new Set(raw.exts ?? ['.ts', '.tsx', '.astro']),
    skipDirs: new Set(raw.skipDirs ?? ['node_modules', 'dist', 'tests']),
    patterns,
    astRuleDirs,
    gate: { file: raw.gate?.file ?? ['critical', 'high'], staged: raw.gate?.staged ?? ['critical', 'high'] },
    suppressionsPath: raw.suppressions
      ? (isAbsolute(raw.suppressions) ? raw.suppressions : resolve(configDir, raw.suppressions))
      : join(configDir, 'suppressions.json'),
    fixturesDirs: [BASELINE_FIXTURES_DIR, raw.fixtures ? resolve(configDir, raw.fixtures) : null].filter(Boolean),
  };
}
```

- [ ] **Step 2: Test against the baseline self-test config**

Run:
```bash
cd /home/user/Projects/slop-gate
node -e "import('./src/config.mjs').then(async m=>{const c=await m.resolveConfig('rules/baseline/selftest.config.mjs');console.log(c.patterns.map(p=>p.id).join(','), '| astDirs', c.astRuleDirs.length)})"
```
Expected: lists `no-stubs-placeholder,ts-suppress-added,as-any-cast,raw-hex-color,kv-binding-usage | astDirs 1`.

- [ ] **Step 3: Test unknown-baseline failure**

Run:
```bash
cd /home/user/Projects/slop-gate
node -e "import('./src/config.mjs').then(m=>m.resolveConfig('/dev/null')).catch(e=>{console.log('OK', e.message.slice(0,30))})"
```
Expected: `OK slop-gate: config not found:` (loud failure path works).

- [ ] **Step 4: Commit**

```bash
git add src/config.mjs
git commit -m "feat: config resolver (baseline opt-in + project packs + path resolution)"
```

---

### Task 8: Regex engine

**Wave:** 3
**Blocks:** T10, T11
**Blocked by:** T1 (uses enumerate from T4 — already in repo by wave 3)

**Files:**
- Create: `/home/user/Projects/slop-gate/src/regex-engine.mjs`

Port `searchPattern` (scan.mjs:121-142) + `runPatternScan` (169-236, stripped of multideal-specific `client-imports-server` special-case and scoring — gate only needs hits) + `collectRegexViolations` (898-923). Read files relative to `config.repoRoot`. `pathMatchesGlobs` ported from scan.mjs:113-118.

- [ ] **Step 1: Write `src/regex-engine.mjs`**

```js
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { listSourceFiles } from './enumerate.mjs';
import { lineHash } from './suppressions.mjs';

function pathMatchesGlobs(filePath, globs) {
  if (!globs?.length) return false;
  return globs.some((g) => {
    const norm = g.replace(/\*\*/g, '§').replace(/\*/g, '[^/]*').replace(/§/g, '.*');
    return new RegExp(norm + '$').test(filePath);
  });
}

function searchPattern(config, files, pattern, excludeGlobs) {
  const re = new RegExp(pattern);
  const byFile = new Map();
  for (const file of files) {
    if (pathMatchesGlobs(file, excludeGlobs)) continue;
    const lines = readFileSync(join(config.repoRoot, file), 'utf8').split('\n');
    for (let i = 0; i < lines.length; i++) {
      if (re.test(lines[i])) {
        if (!byFile.has(file)) byFile.set(file, []);
        byFile.get(file).push(i + 1);
      }
    }
  }
  return byFile;
}

/**
 * @param {import('./config.mjs').ResolvedConfig} config
 * @param {{ staged?:boolean, file?:string }} opts
 * @returns {{ id:string, severity:string, category:string, resolution:string, files:string[], byFile:Map }[]}
 */
export function runPatternScan(config, opts = {}) {
  const files = listSourceFiles(config, opts);
  const fileMode = !!opts.file;
  const findings = [];
  for (const p of config.patterns) {
    if (fileMode && (p.minFiles ?? 1) > 1) continue; // cross-file thresholds meaningless on one file
    let byFile;
    try { byFile = searchPattern(config, files, p.pattern, p.excludeGlobs); }
    catch { continue; }
    const hitFiles = [...byFile.keys()].sort();
    if (hitFiles.length < (p.minFiles ?? 1)) continue;
    findings.push({ id: p.id, severity: p.severity, category: p.category, resolution: p.resolution, files: hitFiles, byFile });
  }
  return findings;
}

/** Expand findings into per-line violations (critical/high gated upstream). */
export function collectRegexViolations(config, findings) {
  const violations = [];
  for (const f of findings) {
    const re = new RegExp(config.patterns.find((p) => p.id === f.id).pattern);
    for (const file of f.files) {
      const lines = readFileSync(join(config.repoRoot, file), 'utf8').split('\n');
      for (let i = 0; i < lines.length; i++) {
        if (re.test(lines[i])) {
          violations.push({
            id: f.id, severity: f.severity, category: f.category, file, line: i + 1,
            lineHash: lineHash(lines[i]),
            text: lines[i].trim().slice(0, 90),
            resolution: f.resolution, engine: 'regex',
          });
        }
      }
    }
  }
  return violations;
}
```

- [ ] **Step 2: Test against baseline fixture (no-stubs canary lives in fixture? add a hit)**

Run:
```bash
cd /home/user/Projects/slop-gate
printf 'export const y = 1; // TODO: implement later\n' > rules/baseline/fixtures/src/hit.ts
node -e "(async()=>{const {resolveConfig}=await import('./src/config.mjs');const {runPatternScan,collectRegexViolations}=await import('./src/regex-engine.mjs');const c=await resolveConfig('rules/baseline/selftest.config.mjs');const v=collectRegexViolations(c,runPatternScan(c));console.log(v.map(x=>x.id).join(','))})()"
rm rules/baseline/fixtures/src/hit.ts
```
Expected: output contains `no-stubs-placeholder`.

- [ ] **Step 3: Commit**

```bash
git add src/regex-engine.mjs
git commit -m "feat: regex engine (pattern scan + violation expansion)"
```

---

### Task 9: Gate report printer

**Wave:** 3
**Blocks:** T10
**Blocked by:** T1

**Files:**
- Create: `/home/user/Projects/slop-gate/src/report.mjs`

Port `printGateReport` (scan.mjs:925-943); replace the `/md-slopgate` reference with a generic instruction.

- [ ] **Step 1: Write `src/report.mjs`**

```js
export function printGateReport(violations, mode) {
  const R = '\x1b[31m'; const Y = '\x1b[33m'; const B = '\x1b[1m'; const D = '\x1b[2m'; const Z = '\x1b[0m';
  const title = mode === 'file'
    ? 'SLOP-GATE — VIOLATIONS IN EDITED FILE               '
    : 'VIOLATIONS IN STAGED FILES — COMMIT BLOCKED         ';
  process.stderr.write(`\n${B}${R}╔═ SLOP-GATE ═════════════════════════════════════════╗${Z}\n`);
  process.stderr.write(`${B}${R}║ ${title}║${Z}\n`);
  process.stderr.write(`${B}${R}╚═════════════════════════════════════════════════════╝${Z}\n\n`);
  for (const v of violations) {
    const C = v.severity === 'critical' ? R : Y;
    process.stderr.write(`${B}${C}[${v.severity.toUpperCase()}]${Z} ${B}${v.id}${Z} ${D}${v.file}:${v.line}${Z}\n`);
    process.stderr.write(`  ${D}×${Z} ${v.text}\n`);
    process.stderr.write(`  ${B}✓${Z} ${v.resolution}\n\n`);
  }
  const files = new Set(violations.map((v) => v.file)).size;
  const tail = mode === 'file' ? 'Fix now while context is hot.' : 'Fix → retry commit.';
  process.stderr.write(`${B}${violations.length} violation(s) in ${files} file(s). ${tail}${Z}\n`);
  process.stderr.write(`False positive? NEVER edit suppressions.json yourself — ask the user via AskUserQuestion.\n\n`);
}
```

- [ ] **Step 2: Smoke-test render**

Run:
```bash
cd /home/user/Projects/slop-gate
node -e "import('./src/report.mjs').then(m=>m.printGateReport([{severity:'high',id:'x',file:'a.ts',line:3,text:'foo',resolution:'bar'}],'file'))" 2>&1 | grep -q 'COMMIT\|EDITED' && echo OK
```
Expected: `OK`.

- [ ] **Step 3: Commit**

```bash
git add src/report.mjs
git commit -m "feat: gate report printer"
```

---

### Task 10: Gate orchestrator

**Wave:** 4
**Blocks:** T12, T14, T15
**Blocked by:** T2, T3, T7, T8, T9

**Files:**
- Create: `/home/user/Projects/slop-gate/src/gate.mjs`

Port `runGate` (scan.mjs:1016-1043) but: take resolved `config`; use `config.gate[mode]` for the severity filter instead of hardcoded critical/high; **return `{ violations, code }`** rather than calling `process.exit` (cli owns exit — enables golden-file tests). Drops multideal's `runApiGuardScan` (project-specific; deferred).

- [ ] **Step 1: Write `src/gate.mjs`**

```js
import { runPatternScan, collectRegexViolations } from './regex-engine.mjs';
import { runAstGrepScan } from './ast-engine.mjs';
import { loadSuppressions, isSuppressed, lineHash } from './suppressions.mjs';
import { listSourceFiles } from './enumerate.mjs';
import { printGateReport } from './report.mjs';

/**
 * @param {'file'|'staged'} mode
 * @param {import('./config.mjs').ResolvedConfig} config
 * @returns {{ violations:any[], code:number }}
 */
export function runGate(mode, config) {
  const opts = mode === 'staged' ? { staged: true } : { file: config._fileTarget };
  const files = listSourceFiles(config, opts);
  if (files.length === 0) return { violations: [], code: 0 };

  const allow = new Set(config.gate[mode] ?? ['critical', 'high']);
  const sup = loadSuppressions(config.suppressionsPath);
  if (sup.error) process.stderr.write(`⚠ SLOP-GATE: suppressions.json malformed (${sup.error}) — treating as EMPTY\n`);

  let violations = collectRegexViolations(config, runPatternScan(config, opts))
    .filter((v) => allow.has(v.severity));

  const ast = runAstGrepScan(config, files);
  if (!ast.available) process.stderr.write(`⚠ SLOP-GATE: ${ast.errors.join('; ')}\n`);
  for (const e of ast.available ? ast.errors : []) process.stderr.write(`⚠ SLOP-GATE ast-grep: ${e}\n`);
  for (const v of ast.violations) {
    if (allow.has(v.severity)) violations.push({ ...v, lineHash: lineHash(v.fullLine) });
  }

  violations = violations.filter((v) => !isSuppressed(sup.entries, v));

  if (violations.length === 0) return { violations, code: 0 };
  printGateReport(violations, mode);
  return { violations, code: 1 };
}
```

- [ ] **Step 2: Test gate exit code on a planted critical**

Run:
```bash
cd /home/user/Projects/slop-gate
printf 'const z = foo as any; // for now\n' > rules/baseline/fixtures/src/hit.ts
node -e "(async()=>{const {resolveConfig}=await import('./src/config.mjs');const {runGate}=await import('./src/gate.mjs');const c=await resolveConfig('rules/baseline/selftest.config.mjs');const r=runGate('staged',c);console.log('code',r.code,'n',r.violations.length)})()" 2>/dev/null
rm rules/baseline/fixtures/src/hit.ts
```
Expected: `code 1 n 2` (as-any + no-stubs hits; staged scans full roots).

- [ ] **Step 3: Commit**

```bash
git add src/gate.mjs
git commit -m "feat: gate orchestrator (returns {violations,code}, config-driven severity)"
```

---

### Task 11: Self-test runner

**Wave:** 4
**Blocks:** T12
**Blocked by:** T2, T3, T7, T8, T6

**Files:**
- Create: `/home/user/Projects/slop-gate/src/selftest.mjs`

Generalize `runSelfTest` (scan.mjs:1051-1091): every `config.patterns[i].canary` must match its own regex; the ast canary (`slopgate-canary`) must fire on baseline fixtures (warn-only if binary missing). Returns a code.

- [ ] **Step 1: Write `src/selftest.mjs`**

```js
import { runAstGrepScan } from './ast-engine.mjs';

/** @param {import('./config.mjs').ResolvedConfig} config */
export function runSelfTest(config) {
  let failed = 0;
  for (const p of config.patterns) {
    if (!p.canary) { console.error(`WARN ${p.id}: no canary — cannot prove rule still fires`); continue; }
    let re;
    try { re = new RegExp(p.pattern); } catch (e) { console.error(`FAIL ${p.id}: regex invalid: ${e}`); failed++; continue; }
    if (!re.test(p.canary)) { console.error(`FAIL ${p.id}: canary not matched: ${p.canary}`); failed++; }
    else console.error(`OK ${p.id}`);
  }
  const ast = runAstGrepScan(config, null);
  if (!ast.available) {
    console.error(`WARN ast-grep unavailable — bucket-B self-test skipped: ${ast.errors.join('; ')}`);
  } else if (!ast.violations.some((v) => v.id === 'slopgate-canary')) {
    console.error('FAIL ast-grep canary: slopgate-canary did not fire on fixtures'); failed++;
  } else {
    console.error(`OK ast-grep canary (${ast.violations.length} fixture violations)`);
  }
  return failed ? 1 : 0;
}
```

- [ ] **Step 2: Run self-test (regex canaries must pass; ast warn-or-pass)**

Run:
```bash
cd /home/user/Projects/slop-gate
node -e "(async()=>{const {resolveConfig}=await import('./src/config.mjs');const {runSelfTest}=await import('./src/selftest.mjs');const c=await resolveConfig('rules/baseline/selftest.config.mjs');process.exit(runSelfTest(c))})()"; echo "exit=$?"
```
Expected: `OK no-stubs-placeholder` … through all 5 baseline ids; ast line is OK or WARN; `exit=0`.

- [ ] **Step 3: Commit**

```bash
git add src/selftest.mjs
git commit -m "feat: self-test runner (canary-per-rule + ast fixture canary)"
```

---

### Task 12: CLI dispatcher

**Wave:** 5
**Blocks:** T13, T14, T15
**Blocked by:** T5, T7, T10, T11

**Files:**
- Create: `/home/user/Projects/slop-gate/src/cli.mjs`

Parse args, resolve config, dispatch to gate / selftest / init, own `process.exit`. `--config` required for gate/self-test; `init` takes a target dir and needs no config.

- [ ] **Step 1: Write `src/cli.mjs`**

```js
import { resolveConfig } from './config.mjs';
import { runGate } from './gate.mjs';
import { runSelfTest } from './selftest.mjs';
import { runInit } from './init.mjs';

const args = process.argv.slice(2);
const has = (f) => args.includes(f);
const valOf = (f) => { const i = args.indexOf(f); return i === -1 ? null : args[i + 1]; };

async function main() {
  if (has('init')) {
    const dir = valOf('init') || process.cwd();
    process.exit(runInit(dir));
  }
  const configPath = valOf('--config');
  if (!configPath) { process.stderr.write('slop-gate: --config <path> required\n'); process.exit(2); }
  const config = await resolveConfig(configPath);

  if (has('--self-test')) process.exit(runSelfTest(config));
  if (has('--staged')) process.exit(runGate('staged', config).code);
  const fileTarget = valOf('--file');
  if (fileTarget) { config._fileTarget = fileTarget; process.exit(runGate('file', config).code); }

  process.stderr.write('slop-gate: no mode (use --staged | --file <p> | --self-test | init [dir])\n');
  process.exit(2);
}
main().catch((e) => { process.stderr.write(`slop-gate: ${e?.stack || e}\n`); process.exit(1); });
```

- [ ] **Step 2: End-to-end via bin**

Run:
```bash
cd /home/user/Projects/slop-gate
node bin/slop-gate --self-test --config rules/baseline/selftest.config.mjs; echo "selftest=$?"
T=$(mktemp -d); node bin/slop-gate init $T >/dev/null && test -f $T/.slop-gate/config.mjs && echo "init OK"
```
Expected: self-test prints OK lines, `selftest=0`; `init OK`.

- [ ] **Step 3: Commit**

```bash
git add src/cli.mjs
git commit -m "feat: cli dispatcher (gate/self-test/init)"
```

---

### Task 13: Hook scripts

**Wave:** 6
**Blocks:** T15
**Blocked by:** T12

**Files:**
- Create: `/home/user/Projects/slop-gate/hooks/commit-hook.sh`
- Create: `/home/user/Projects/slop-gate/hooks/edit-hook.sh`

Generalize multideal hooks: locate the project's `.slop-gate/config.mjs` from the repo root (via `git rev-parse --show-toplevel` of the edited file / cwd) instead of a hardcoded path. Fail-open on edit (exit 0); fail-closed on commit (exit 1). Both no-op (exit 0) when no `.slop-gate/config.mjs` exists.

- [ ] **Step 1: Write `hooks/commit-hook.sh`**

```bash
#!/usr/bin/env bash
# Slop-gate PreToolUse hook — runs --staged before a git commit. Exit 1 → commit blocked.
TOOL_JSON=$(cat)
CMD=$(node -e "
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)
echo "$CMD" | grep -qE '(git[[:space:]]+commit|commit_push\.sh|deploy\.sh)' || exit 0

ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
CONFIG="$ROOT/.slop-gate/config.mjs"
[ -f "$CONFIG" ] || exit 0
exec node /home/user/Projects/slop-gate/bin/slop-gate --staged --config "$CONFIG"
```

- [ ] **Step 2: Write `hooks/edit-hook.sh`**

```bash
#!/usr/bin/env bash
# Slop-gate PostToolUse hook — single-file scan after Edit/Write.
# Exit 2 → stderr feeds back into the agent turn. FAIL-OPEN: any error/timeout → exit 0.
TOOL_JSON=$(cat)
FILE=$(node -e "
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
[ -n "$FILE" ] || exit 0
case "$FILE" in *.test.ts|*.test.tsx) exit 0 ;; *.ts|*.tsx|*.astro) ;; *) exit 0 ;; esac

ROOT=$(git -C "$(dirname "$FILE")" rev-parse --show-toplevel 2>/dev/null) || exit 0
CONFIG="$ROOT/.slop-gate/config.mjs"
[ -f "$CONFIG" ] || exit 0

OUT=$(timeout 5 node /home/user/Projects/slop-gate/bin/slop-gate --file "$FILE" --config "$CONFIG" 2>&1)
[ "$?" -eq 1 ] && { echo "$OUT" >&2; exit 2; }
exit 0
```

- [ ] **Step 3: Make executable, test commit-hook no-ops outside a configured repo, commit**

Run:
```bash
cd /home/user/Projects/slop-gate
chmod +x hooks/commit-hook.sh hooks/edit-hook.sh
echo '{"tool_input":{"command":"git commit -m x"}}' | hooks/commit-hook.sh; echo "noop_exit=$?"
git add hooks/commit-hook.sh hooks/edit-hook.sh
git commit -m "feat: generic commit + edit hooks (auto-locate project .slop-gate)"
```
Expected: `noop_exit=0` (slop-gate repo has no `.slop-gate/config.mjs`, so hook no-ops).

---

### Task 14: Re-point multideal as first consumer (gate parity)

**Wave:** 7
**Blocks:** T15 (proves extraction faithful before zync adopts)
**Blocked by:** T12

**Files:**
- Create: `/home/user/Projects/multideal/.slop-gate/config.mjs`
- Create: `/home/user/Projects/multideal/.slop-gate/rules/multideal-patterns.mjs`
- Create: `/home/user/Projects/multideal/.slop-gate/suppressions.json`

Port multideal's gate-relevant critical/high `PATTERNS` (from `apps/web/scripts/code-quality/patterns.mjs`) into a project rule pack, and point a `.slop-gate/config.mjs` at `apps/web/src`. Prove the global gate reports the SAME staged violations as multideal's existing `scan.mjs --staged` on the same staged set (golden-file parity).

- [ ] **Step 1: Extract multideal critical/high patterns into a project pack**

Read `/home/user/Projects/multideal/apps/web/scripts/code-quality/patterns.mjs`. Copy every object whose `severity` is `critical` or `high` into the array below (preserve `id`, `pattern`, `severity`, `category`, `title`, `description`, `resolution`, `excludeGlobs`, `minFiles`, `canonicalImport`, `userVisible`). Create `/home/user/Projects/multideal/.slop-gate/rules/multideal-patterns.mjs`:

```js
// multideal project rule pack — pinned in multideal's git. Critical/high gate patterns
// extracted from apps/web/scripts/code-quality/patterns.mjs (2026-06-10).
export default [
  // ⟨paste each critical/high Pattern object here, verbatim from patterns.mjs⟩
];
```

- [ ] **Step 2: Write `/home/user/Projects/multideal/.slop-gate/config.mjs`**

```js
export default {
  roots: ['apps/web/src'],
  exts: ['.ts', '.tsx', '.astro'],
  skipDirs: ['node_modules', 'dist', 'tests', 'regression', 'playwright-purchase-report', 'html-cache'],
  baseline: ['no-stubs', 'ts-suppress', 'as-any'],
  rules: ['./rules/multideal-patterns.mjs'],
  astRules: '../apps/web/scripts/code-quality/ast-rules/rules',
  gate: { file: ['critical', 'high'], staged: ['critical', 'high'] },
  suppressions: './suppressions.json',
  fixtures: '../apps/web/scripts/code-quality/fixtures',
};
```

- [ ] **Step 3: Seed suppressions from multideal's existing file**

Run:
```bash
cp /home/user/Projects/multideal/apps/web/scripts/code-quality/suppressions.json \
   /home/user/Projects/multideal/.slop-gate/suppressions.json 2>/dev/null \
   || echo '{"version":1,"entries":[]}' > /home/user/Projects/multideal/.slop-gate/suppressions.json
```
Expected: a `suppressions.json` exists in `.slop-gate/`.

- [ ] **Step 4: Golden-file parity — stage a known-bad file, diff old vs new gate output**

Run:
```bash
cd /home/user/Projects/multideal
# capture OLD gate (existing multideal scan) and NEW gate (global engine) on same staged set
git add -A apps/web/src 2>/dev/null
node apps/web/scripts/code-quality/scan.mjs --staged > /tmp/old-gate.txt 2>&1; echo "old=$?"
node /home/user/Projects/slop-gate/bin/slop-gate --staged --config .slop-gate/config.mjs > /tmp/new-gate.txt 2>&1; echo "new=$?"
# Compare the set of (id, file:line) tuples — formatting differs, the violation SET must match
grep -oE '\b[a-z0-9-]+ +[^ ]+:[0-9]+' /tmp/old-gate.txt | sort -u > /tmp/old-set.txt
grep -oE '\b[a-z0-9-]+ +[^ ]+:[0-9]+' /tmp/new-gate.txt | sort -u > /tmp/new-set.txt
diff /tmp/old-set.txt /tmp/new-set.txt && echo "PARITY OK" || echo "PARITY DIFF — investigate"
git reset apps/web/src >/dev/null 2>&1
```
Expected: identical exit codes; `PARITY OK`. If `PARITY DIFF`: the new gate must be a SUPERSET only by baseline packs (no-stubs/ts-suppress/as-any) the old gate lacked — confirm every extra line belongs to a baseline pack id; any MISSING multideal id means a pattern wasn't ported (fix Step 1).

- [ ] **Step 5: Commit (in multideal repo, runtime config only)**

```bash
cd /home/user/Projects/multideal
git add .slop-gate/config.mjs .slop-gate/rules/multideal-patterns.mjs .slop-gate/suppressions.json
git commit -m "feat: adopt global slop-gate engine (.slop-gate config + pattern pack)"
```

---

### Task 15: Bootstrap zync.is

**Wave:** 8
**Blocks:** —
**Blocked by:** T14

**Files:**
- Create: `/home/user/Projects/zync.is/.slop-gate/config.mjs` (via `slop-gate init`, then edit)
- Create: `/home/user/Projects/zync.is/.slop-gate/rules/zync-patterns.mjs`
- Create: `/home/user/Projects/zync.is/.slop-gate/rules/ast/neon-dot-query.yml`
- Create: `/home/user/Projects/zync.is/.slop-gate/rules/ast/zync-ui-flat-import.yml`
- Modify: `/home/user/Projects/zync.is/.claude/settings.json` (add hooks)

Scaffold, author the 5 zync project rules (from zc-* learned rules), drive each to zero hits BEFORE enabling (zero-tolerance), wire hooks.

- [ ] **Step 1: Scaffold**

Run:
```bash
cd /home/user/Projects/zync.is
node /home/user/Projects/slop-gate/bin/slop-gate init /home/user/Projects/zync.is
```
Expected: `scaffolded .../.slop-gate/` + hook snippet printed.

- [ ] **Step 2: Write `/home/user/Projects/zync.is/.slop-gate/rules/zync-patterns.mjs`** (regex rules)

```js
// zync.is project rule pack — pinned in zync.is git. Mechanizes zc-* learned rules.
export default [
  {
    id: 'no-html-table', title: 'Raw <table> HTML element',
    category: 'convention', severity: 'high',
    pattern: '<(table|thead|tbody|tr|td|th|tfoot)\\b',
    description: 'zc-ui-dev bans <table>; use <div> + CSS grid.',
    resolution: 'Build tabular layout with <div> grid (see zc-ui-dev TABULAR DATA).',
    excludeGlobs: ['**/*-pdf.ts', '**/pdf-preview.ts'],
    canary: '<table className="x">',
  },
  {
    id: 'oklch-only', title: 'Hardcoded hex/rgb/hsl color',
    category: 'convention', severity: 'high',
    pattern: '#[0-9a-fA-F]{3,8}\\b|\\brgb\\(|\\brgba\\(|\\bhsl\\(',
    description: 'zc-ui-dev: OKLCH tokens only; hex/rgb/hsl banned in source.',
    resolution: 'Use a CSS custom property from packages/ui tokens.',
    excludeGlobs: ['**/tokens/**', '**/tokens.css'],
    canary: 'color: #ff0044;',
  },
  {
    id: 'no-raw-drizzle-route', title: 'Drizzle import inside a route handler',
    category: 'boundary', severity: 'high',
    pattern: "from '@zync/db'",
    description: 'zc-dba: routes must call query functions, never import drizzle directly.',
    resolution: "Import from '@zync/db/queries/*' instead.",
    canary: "import { db } from '@zync/db'",
  },
];
```

- [ ] **Step 3: Write `/home/user/Projects/zync.is/.slop-gate/rules/ast/neon-dot-query.yml`**

```yaml
id: neon-dot-query
language: tsx
severity: error
message: ".query() on a neon http client — use sql`...` tagged template"
note: '{"severity":"high","category":"convention","resolution":"neon http client has no .query(); use sql`...` tagged template (zc-dba neon-http-tagged-template)."}'
rule:
  pattern: $S.query($$$)
constraints:
  S: { regex: 'sql|neon|client' }
```

- [ ] **Step 4: Write `/home/user/Projects/zync.is/.slop-gate/rules/ast/zync-ui-flat-import.yml`**

```yaml
id: zync-ui-flat-import
language: tsx
severity: error
message: "Flat compound-component import from @zync/ui (use Dialog.Content)"
note: '{"severity":"high","category":"convention","resolution":"@zync/ui uses compound API: import Dialog, use Dialog.Content (zc-ui-dev zync-ui-compound-api)."}'
rule:
  pattern: import { $$$A } from '@zync/ui'
constraints:
  A: { regex: 'DialogContent|DialogHeader|DialogFooter|DialogTitle' }
```

- [ ] **Step 5: Edit `.slop-gate/config.mjs`** — set zync roots + enable packs

Replace the generated `config.mjs` body with:

```js
export default {
  roots: ['apps/zync-app/src', 'apps/zync-www/src', 'apps/zync-admin/src'],
  exts: ['.ts', '.tsx', '.astro'],
  skipDirs: ['node_modules', 'dist', 'tests', '.worktrees'],
  baseline: ['no-stubs', 'ts-suppress', 'as-any', 'kv-ban'],
  rules: ['./rules/zync-patterns.mjs'],
  astRules: './rules/ast',
  gate: { file: ['critical', 'high'], staged: ['critical', 'high'] },
  suppressions: './suppressions.json',
  fixtures: './fixtures',
};
```

- [ ] **Step 6: Cleanup-to-zero gate (zero-tolerance before enabling)**

Run:
```bash
cd /home/user/Projects/zync.is
node /home/user/Projects/slop-gate/bin/slop-gate --self-test --config .slop-gate/config.mjs; echo "selftest=$?"
# Full-source dry run: list current hits so they can be driven to zero before hooks go live
node -e "(async()=>{const {resolveConfig}=await import('/home/user/Projects/slop-gate/src/config.mjs');const {runPatternScan,collectRegexViolations}=await import('/home/user/Projects/slop-gate/src/regex-engine.mjs');const {runAstGrepScan}=await import('/home/user/Projects/slop-gate/src/ast-engine.mjs');const c=await resolveConfig('.slop-gate/config.mjs');const {listSourceFiles}=await import('/home/user/Projects/slop-gate/src/enumerate.mjs');const reg=collectRegexViolations(c,runPatternScan(c)).filter(v=>['critical','high'].includes(v.severity));const ast=runAstGrepScan(c,null);const counts={};for(const v of [...reg,...ast.violations])counts[v.id]=(counts[v.id]||0)+1;console.log(JSON.stringify(counts,null,2))})()"
```
Expected: `selftest=0`. The counts object lists existing violations per rule id. For each non-zero id, either fix the offending source (preferred) or, if it is a genuine false positive, append to `.slop-gate/suppressions.json` only after user approval via AskUserQuestion. Re-run until the counts object is `{}`. **Do not wire hooks (Step 7) until counts are empty** — zero-tolerance.

- [ ] **Step 7: Wire hooks into `.claude/settings.json`**

Read `/home/user/Projects/zync.is/.claude/settings.json`. Merge these hook entries into the existing `hooks` object (preserve any existing hooks — append to the matcher arrays, do not replace):

```jsonc
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash", "hooks": [{ "type": "command", "command": "/home/user/Projects/slop-gate/hooks/commit-hook.sh" }] }
    ],
    "PostToolUse": [
      { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "/home/user/Projects/slop-gate/hooks/edit-hook.sh" }] }
    ]
  }
}
```

- [ ] **Step 8: Verify live gate end-to-end**

Run:
```bash
cd /home/user/Projects/zync.is
# Edit-hook: plant a violation in a temp source file, simulate PostToolUse JSON
echo 'export const c = "#ff0044";' > apps/zync-app/src/__slopgate_probe.ts
echo "{\"tool_input\":{\"file_path\":\"$PWD/apps/zync-app/src/__slopgate_probe.ts\"}}" | /home/user/Projects/slop-gate/hooks/edit-hook.sh; echo "edit_hook=$?"
rm apps/zync-app/src/__slopgate_probe.ts
```
Expected: `edit_hook=2` with the oklch-only violation printed (hook fed the agent). Confirms the live wiring fires.

- [ ] **Step 9: Commit (runtime config only — see Commit notes)**

```bash
cd /home/user/Projects/zync.is
git add .slop-gate/config.mjs .slop-gate/rules .slop-gate/suppressions.json .claude/settings.json
git commit -m "feat: adopt slop-gate (zync rule pack + edit/commit hooks)"
```
If the project git allow-list rejects any of these paths, STOP and ask the user before overriding.

---

## Self-Review

**1. Spec coverage:**
- Global engine + auto-latest → T1–T13 (engine in own repo, referenced by path). ✅
- Layered rules: baseline opt-in (T6 packs, T7 resolver names→packs) + project packs (T7 loader, T14/T15 packs). ✅
- Per-project config seam (`config.mjs`) → T7. ✅
- Modes `--file`/`--staged`/`--self-test` → T10/T11/T12. ✅
- ast-grep graceful degradation → T3 (empty/ missing → no brick), T10 warns. ✅
- Suppressions per project → T2, wired in T10. ✅
- Hooks (edit PostToolUse, commit PreToolUse), auto-locate config → T13, wired T15. ✅
- `slop-gate init` → T5/T12. ✅
- Migration: extract engine (T1–T13), multideal first consumer + golden parity (T14), zync bootstrap (T15). ✅
- Zero-tolerance cleanup-before-enable → T15 Step 6. ✅
- Baseline universals from global CLAUDE.md (no-stubs, ts-suppress, as-any, raw-hex, kv-ban) → T6. ✅
- Deferred (full audit report, LLM judge) → stated in header Scope. ✅

**2. Placeholder scan:** One intentional paste-point — T14 Step 1 ("paste each critical/high Pattern object verbatim from patterns.mjs"). This is a mechanical copy of existing, in-repo objects, not an under-specified instruction; the source file and exact fields to preserve are named. All other steps carry complete runnable code. No TBD/TODO-as-work.

**3. Type consistency:** Module export names match the locked "Shared contracts" list across T2–T12. `runGate` returns `{violations,code}` in T10 and is consumed as `.code` in T12. `resolveConfig` shape (T7) matches every consumer's field access (`repoRoot`, `rootsRel`, `patterns`, `astRuleDirs`, `gate[mode]`, `suppressionsPath`). `runAstGrepScan(config, files)` signature consistent T3/T10/T11. `_fileTarget` set in T12, read in T10. ✅

**4. Wave plan check:** Every task has Wave/Blocks/Blocked-by. No two tasks in a wave share a file (wave 2: 5 disjoint paths; wave 3: 3 disjoint; wave 4: 2 disjoint). Dependency order holds: config (T7) after baseline index (T6); gate (T10) after its imports (T2,T3,T7,T8,T9); cli (T12) after gate+selftest+init; hooks (T13) after cli; multideal (T14) after cli; zync (T15) after multideal. ✅

## Architecture Decisions (carried from design)

- regex-engine and ast-engine kept as separate modules (two real adapters). 
- `config.mjs` resolver is the single project-coupling seam (deletion test: removing it scatters baseline-merge + path resolution into every caller). 
- Baseline rules are data arrays, not a plugin system (single-adapter test). 
- Rejected: published npm package, vendored per-project copy (contradict locked decisions).
