# Slop-Gate × zync.is Hardening 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:** Repair zync's slop-gate installation (dangling fixtures root, unproven ast rule, missing git hook) and extend the global engine so the tsc checker works in monorepos, then enable it for zync's three apps.

**Architecture:** Two repos. Engine (`/home/user/Projects/slop-gate`) gets two additive changes: `checkers/tsc.mjs` accepts a tsconfig **array** + PATH-tsc fallback, and `selftest.mjs` asserts every *project* ast rule fires on the project fixtures (plus FAIL on dangling roots/fixtures paths — the exact rot class found). Zync (`/home/user/Projects/zync.is/.slop-gate/`) gets fixed `config.mjs` roots, a fixtures canary file, an enabled tsc checker, a git-level pre-commit hook via `install-hooks`, and a ratchet baseline only if pre-existing tsc errors exist.

**Tech Stack:** Node ESM (.mjs), ast-grep CLI, TypeScript compiler, git hooks. No frameworks; engine tests are plain `node <file>.test.mjs` scripts with `assert(label, ok)` + `process.exit(failed ? 1 : 0)`.

**Spec:** `docs/specs/2026-06-10-slop-gate-zync-hardening-design.md` (F1–F4, design §3.1–3.6, §4).

**Context for the engineer:**
- Engine repo is its own git repo at `/home/user/Projects/slop-gate` (HEAD d97f60d). Zync repo at `/home/user/Projects/zync.is`. Commit each repo separately.
- Engine test files (`src/**/*.test.mjs`) ARE committed in the engine repo — that's its convention. In zync, commit `.slop-gate/**` and `docs/**` (project convention commits specs/plans).
- A Claude-Code PreToolUse hook intercepts `git commit` run from this session and runs the gate first. That is expected; it does NOT replace the git-level hook this plan installs (Task 5 verifies the git hook by executing `.git/hooks/pre-commit` directly).
- Resolved config shape comes from `src/config.mjs` `resolveConfig()`: `config.roots` (absolute), `config.rootsRel`, `config.astRuleDirs` (`[BASELINE_AST_DIR, <project ast dir if exists>]`), `config.fixturesDirs` (`[BASELINE_FIXTURES_DIR, <project fixtures abs>]`), `config.astDisable` (Set), `config.checkers` (object).

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | Task 1 (engine tsc), Task 2 (engine selftest), Task 3 (zync fixtures) | `slop-gate/src/checkers/tsc.mjs` + `tsc.test.mjs`; `slop-gate/src/selftest.mjs` + `selftest.test.mjs`; `zync.is/.slop-gate/fixtures/src/canary.tsx` | ✅ no overlap |
| 2 | Task 4 (zync config + verify + conditional baseline + commit) | `zync.is/.slop-gate/config.mjs`, maybe `zync.is/.slop-gate/baseline.json` | single task |
| 3 | Task 5 (git hook install + block verification) | `zync.is/.git/hooks/pre-commit` (machine-local, not committed) | single task |
| 4 | Task 6 (docs reconciliation + commit) | `zync.is/.claude/skills/zc-orchestrate/SKILL.md`, `zync.is/docs/specs/2026-06-10-slop-gate-global-architecture-design.md`, `zync.is/docs/specs/2026-06-10-slop-gate-zync-hardening-design.md` | single task |

Dependencies: Task 4 needs Task 1 (array tsconfig support), Task 2 (project-ast self-test assertion), Task 3 (fixtures dir must exist or new self-test FAILs). Task 5 needs Task 4 (hook runs `--staged` against the final config; baseline must exist before any commit attempt if tsc has pre-existing errors). Task 6 needs Task 5 (documents the installed state).

---

### Task 1: Engine — tsc checker: tsconfig array + PATH fallback (spec 3.4)

**Wave:** 1
**Blocks:** Task 4
**Blocked by:** —

**Files:**
- Modify: `/home/user/Projects/slop-gate/src/checkers/tsc.mjs`
- Test: `/home/user/Projects/slop-gate/src/checkers/tsc.test.mjs`

Background: current `detect()` does `join(config.repoRoot, cfg.tsconfig ?? 'tsconfig.json')` — an array value throws `TypeError` in `join`. It also requires `node_modules/.bin/tsc` (via `localBin`) and returns unavailable otherwise. The change: accept `string | string[]`, run `tsc --noEmit -p` per entry concatenating violations, and fall back to PATH `tsc` when no local binary (same precedent as `resolveAstGrepBin` in `src/ast-engine.mjs`). Existing single-string configs must behave identically (back-compat).

- [ ] **Step 1: Extend the test file with failing assertions**

Replace the `// detect` block (lines 18–27 of `src/checkers/tsc.test.mjs`) with the following. Note the PATH-fallback assertions are environment-aware: they compute the expected value from an actual PATH probe, so the test passes both on machines with and without a global tsc.

```js
// detect
import { spawnSync } from 'node:child_process'; // ← move to top imports with the others
const pathHasTsc = spawnSync('tsc', ['--version'], { encoding: 'utf8' }).status === 0;

const root = mkdtempSync(join(tmpdir(), 'slopgate-tsc-'));
assert('no tsconfig → unavailable', tsc.detect({ repoRoot: root }, {}).available === false);
writeFileSync(join(root, 'tsconfig.json'), '{}');
assert('no local tsc → PATH fallback decides', tsc.detect({ repoRoot: root }, {}).available === pathHasTsc);
mkdirSync(join(root, 'node_modules/.bin'), { recursive: true });
writeFileSync(join(root, 'node_modules/.bin/tsc'), '');
assert('tsconfig + local bin → available', tsc.detect({ repoRoot: root }, {}).available === true);
assert('custom tsconfig honored', tsc.detect({ repoRoot: root }, { tsconfig: 'tsconfig.app.json' }).available === false);

// array form (spec 3.4: monorepo support)
writeFileSync(join(root, 'tsconfig.app.json'), '{}');
assert('array: all exist → available',
  tsc.detect({ repoRoot: root }, { tsconfig: ['tsconfig.json', 'tsconfig.app.json'] }).available === true);
assert('array: one missing → unavailable',
  tsc.detect({ repoRoot: root }, { tsconfig: ['tsconfig.json', 'nope.json'] }).available === false);
assert('array: missing reason names the file',
  tsc.detect({ repoRoot: root }, { tsconfig: ['tsconfig.json', 'nope.json'] }).reason === 'no nope.json');
assert('id', tsc.id === 'tsc');
```

(`spawnSync` import goes on the existing `node:child_process`-free import list at the top — add `import { spawnSync } from 'node:child_process';` after the `node:os` import; do not leave it mid-file.)

- [ ] **Step 2: Run test to verify it fails**

Run: `cd /home/user/Projects/slop-gate && node src/checkers/tsc.test.mjs; echo "exit=$?"`
Expected: crash with `TypeError [ERR_INVALID_ARG_TYPE] ... "path" argument must be of type string` (array hits `join`) — or `FAIL: no local tsc → PATH fallback decides` if it gets that far on this machine (global tsc exists at `/usr/local/bin/tsc`, current code returns unavailable). Either failure mode is the expected red.

- [ ] **Step 3: Implement**

Replace `/home/user/Projects/slop-gate/src/checkers/tsc.mjs` in full:

```js
// src/checkers/tsc.mjs
/** tsc --noEmit adapter. Always full-project: a staged change can break a non-staged
 *  file and that MUST fail; pre-existing errors are absorbed by the ratchet baseline.
 *  cfg.tsconfig: string | string[] — monorepos list one tsconfig per package/app.
 *  Binary: local node_modules/.bin/tsc preferred, PATH tsc fallback (ast-grep precedent). */
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { localBin, runTool, sourceLine } from './shared.mjs';

export function resolveTscBin(repoRoot) {
  const local = localBin(repoRoot, 'tsc');
  if (local) return local;
  const probe = spawnSync('tsc', ['--version'], { encoding: 'utf8' });
  if (probe.status === 0) return 'tsc';
  return null;
}

function tsconfigList(cfg) {
  return [].concat(cfg.tsconfig ?? 'tsconfig.json');
}

export function parseTscOutput(stdout) {
  const errors = [];
  for (const raw of stdout.split('\n')) {
    const m = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(raw);
    if (m) {
      errors.push({ file: m[1].replace(/\\/g, '/'), line: Number(m[2]), code: m[4], message: m[5] });
    } else if (errors.length && /^\s+\S/.test(raw)) {
      errors[errors.length - 1].message += ` ${raw.trim()}`;
    }
  }
  return errors;
}

export default {
  id: 'tsc',
  detect(config, cfg) {
    for (const rel of tsconfigList(cfg)) {
      if (!existsSync(join(config.repoRoot, rel))) return { available: false, reason: `no ${rel}` };
    }
    if (!resolveTscBin(config.repoRoot)) return { available: false, reason: 'no tsc binary (local or PATH)' };
    return { available: true };
  },
  run(config, cfg) {
    const bin = resolveTscBin(config.repoRoot);
    const violations = [];
    const errors = [];
    for (const rel of tsconfigList(cfg)) {
      const res = runTool(bin, ['--noEmit', '--pretty', 'false', '-p', join(config.repoRoot, rel)], {
        cwd: config.repoRoot, timeout: (cfg.timeout ?? 120) * 1000,
      });
      if (!res.ok) { errors.push(`tsc(${rel}) failed: ${res.error}`); continue; }
      violations.push(...parseTscOutput(res.stdout).map((e) => ({
        id: `tsc-${e.code}`, severity: 'high', category: 'types',
        file: e.file, line: e.line,
        fullLine: sourceLine(config.repoRoot, e.file, e.line),
        text: e.message.trim().slice(0, 90),
        resolution: 'Fix the type error — do not suppress.',
      })));
    }
    return { violations, errors };
  },
};
```

Notes that matter: `timeout` applies **per tsconfig entry** (each `runTool` call), not to the whole loop. `parseTscOutput` is unchanged byte-for-byte (selftest parser fixtures depend on it). File paths from `tsc -p <abs>` with `cwd: repoRoot` are emitted repo-relative — no rewriting needed.

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd /home/user/Projects/slop-gate && node src/checkers/tsc.test.mjs; echo "exit=$?"`
Expected: all `PASS:` lines, `exit=0`.

Run regression: `cd /home/user/Projects/slop-gate && node src/checkers/shared.test.mjs && node src/config.checkers.test.mjs && node src/gate.tier.test.mjs && npm run self-test; echo "exit=$?"`
Expected: all pass, `exit=0` (self-test prints `OK ...` lines to stderr).

- [ ] **Step 5: Commit (engine repo)**

```bash
cd /home/user/Projects/slop-gate
git add src/checkers/tsc.mjs src/checkers/tsc.test.mjs
git commit -m "feat(tsc): tsconfig array for monorepos + PATH-tsc fallback"
```

---

### Task 2: Engine — self-test proves project ast rules + config path sanity (spec 3.3, F1/F2)

**Wave:** 1
**Blocks:** Task 4
**Blocked by:** —

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

Background: `runSelfTest` currently proves regex canaries, ONE engine ast canary (`slopgate-canary`), and checker output parsers. It does not prove project ast rules (zync's `neon-dot-query` has zero rot-detection) and silently tolerates dangling `roots`/`fixtures` paths (the F1 defect class). Three additions: (a) FAIL when a configured root or fixtures dir does not exist; (b) for every `.yml`/`.yaml` in *project* ast-rule dirs, assert its `id` fires at least once on the fixtures scan; (c) skip rules listed in `config.astDisable` with a `SKIP` line, and keep the existing WARN-degradation when ast-grep is missing.

- [ ] **Step 1: Write the failing test**

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

```js
// src/selftest.test.mjs
// Proves runSelfTest catches: non-firing project ast rules, dangling fixtures dirs.
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { resolveConfig } from './config.mjs';
import { runSelfTest } from './selftest.mjs';

let failed = 0;
function assert(label, ok) { console.log(`${ok ? 'PASS' : 'FAIL'}: ${label}`); if (!ok) failed++; }

const haveAstGrep = spawnSync('ast-grep', ['--version'], { encoding: 'utf8' }).status === 0;
if (!haveAstGrep) {
  console.log('SKIP: ast-grep not on PATH — project-ast self-test assertions not verifiable here');
  process.exit(0);
}

const root = mkdtempSync(join(tmpdir(), 'slopgate-selftest-'));
const sg = join(root, '.slop-gate');
mkdirSync(join(sg, 'rules/ast'), { recursive: true });
mkdirSync(join(sg, 'fixtures/src'), { recursive: true });
mkdirSync(join(root, 'src'), { recursive: true });

writeFileSync(join(sg, 'config.mjs'),
  "export default { roots: ['src'], rules: [], astRules: './rules/ast', fixtures: './fixtures' };\n");
writeFileSync(join(sg, 'rules/ast/test-fire.yml'), [
  'id: test-fire', 'language: tsx', 'severity: error', 'message: test',
  'rule:', '  pattern: dangerouslyNeverWrite($$$)', '',
].join('\n'));
writeFileSync(join(sg, 'fixtures/src/canary.tsx'), 'dangerouslyNeverWrite(1);\n');

const cfg1 = await resolveConfig(join(sg, 'config.mjs'));
assert('firing project ast rule → exit 0', runSelfTest(cfg1) === 0);

writeFileSync(join(sg, 'rules/ast/never-fires.yml'), [
  'id: never-fires', 'language: tsx', 'severity: error', 'message: test',
  'rule:', '  pattern: zzzNeverCalledFn($$$)', '',
].join('\n'));
const cfg2 = await resolveConfig(join(sg, 'config.mjs'));
assert('non-firing project ast rule → exit 1', runSelfTest(cfg2) === 1);

rmSync(join(sg, 'rules/ast/never-fires.yml'));
rmSync(join(sg, 'fixtures'), { recursive: true, force: true });
const cfg3 = await resolveConfig(join(sg, 'config.mjs'));
assert('declared fixtures dir missing → exit 1', runSelfTest(cfg3) === 1);

rmSync(root, { recursive: true, force: true });
process.exit(failed ? 1 : 0);
```

(Notes: tmpdir is not a git repo, so `resolveConfig` falls back to `repoRoot = dirname(configDir)` — the tmp root. `config.fixturesDirs` always also contains the engine BASELINE_FIXTURES_DIR, so the engine `slopgate-canary` keeps firing in all three cases. The self-test stderr noise is expected; only the return code is asserted.)

- [ ] **Step 2: Run test to verify it fails**

Run: `cd /home/user/Projects/slop-gate && node src/selftest.test.mjs; echo "exit=$?"`
Expected: `FAIL: non-firing project ast rule → exit 1` and `FAIL: declared fixtures dir missing → exit 1` (current code ignores both), `exit=1`.

- [ ] **Step 3: Implement in `src/selftest.mjs`**

Three edits.

(3a) Extend imports — line 1 gains `readdirSync`, and add the baseline-ast-dir import after the existing checker imports:

```js
import { readFileSync, existsSync, readdirSync } from 'node:fs';
```
```js
import { BASELINE_AST_DIR } from '../rules/baseline/index.mjs';
```

(3b) Replace the single line `const ast = runAstGrepScan(config, config.fixturesDirs, { rawTargets: true });` (line 26) with config-path sanity + a scan over only the existing dirs:

```js
  // config path sanity: dangling roots/fixtures dirs = silent-zero-results rot (zync F1 class)
  for (const r of config.roots) {
    if (!existsSync(r)) { console.error(`FAIL config: root missing: ${r}`); failed++; }
  }
  const fixturesDirs = [];
  for (const d of config.fixturesDirs) {
    if (!existsSync(d)) { console.error(`FAIL config: fixtures dir missing: ${d}`); failed++; }
    else fixturesDirs.push(d);
  }
  const ast = runAstGrepScan(config, fixturesDirs, { rawTargets: true });
```

(3c) Immediately after the existing engine-canary `if/else if/else` block (after the line `console.error(\`OK ast-grep canary (${ast.violations.length} fixture violations)\`);` and its closing `}`), insert:

```js
  // project ast rules: every rule yml must fire at least once on the fixtures scan.
  const projectAstDirs = (config.astRuleDirs || []).filter((d) => d !== BASELINE_AST_DIR && existsSync(d));
  if (!ast.available) {
    if (projectAstDirs.length) console.error('WARN ast-grep unavailable — project ast rules not verified');
  } else {
    for (const dir of projectAstDirs) {
      for (const f of readdirSync(dir).filter((n) => n.endsWith('.yml') || n.endsWith('.yaml'))) {
        const m = /^id:\s*(\S+)/m.exec(readFileSync(join(dir, f), 'utf8'));
        if (!m) { console.error(`FAIL ast ${f}: no "id:" line`); failed++; continue; }
        const id = m[1];
        if (config.astDisable.has(id)) { console.error(`SKIP ast ${id} (astDisable)`); continue; }
        if (!ast.violations.some((v) => v.id === id)) {
          console.error(`FAIL ast ${id}: did not fire on fixtures — add a trigger to the project fixtures dir`); failed++;
        } else {
          console.error(`OK ast ${id}`);
        }
      }
    }
  }
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd /home/user/Projects/slop-gate && node src/selftest.test.mjs; echo "exit=$?"`
Expected: 3 `PASS:` lines, `exit=0`.

Run regression: `cd /home/user/Projects/slop-gate && npm run self-test; echo "exit=$?"`
Expected: `exit=0` — the baseline selftest config's `roots: ['rules/baseline/fixtures/src']` and fixtures dir both exist in the engine repo, and it has no project ast dirs, so the new checks are no-ops there.

- [ ] **Step 5: Commit (engine repo)**

```bash
cd /home/user/Projects/slop-gate
git add src/selftest.mjs src/selftest.test.mjs
git commit -m "feat(selftest): prove project ast rules fire; FAIL on dangling roots/fixtures paths"
```

---

### Task 3: Zync — project fixtures canary (spec 3.2)

**Wave:** 1
**Blocks:** Task 4
**Blocked by:** —

**Files:**
- Create: `/home/user/Projects/zync.is/.slop-gate/fixtures/src/canary.tsx`

One trigger per project rule from `.slop-gate/rules/zync-patterns.mjs` (regex: `no-html-table`, `oklch-only`, `no-raw-drizzle-route`, `zync-ui-flat-import`, `no-zync-ui-table-primitive`) plus the one project ast rule `neon-dot-query` (`$S.query($$$)` with `S` matching `/sql|neon|client/` — `sqlClient.query(...)` satisfies it). The file never compiles (not in any tsconfig) and is never in scan roots; only the self-test reads it via `fixturesDirs`.

- [ ] **Step 1: Create the fixture**

Write `/home/user/Projects/zync.is/.slop-gate/fixtures/src/canary.tsx`:

```tsx
// slop-gate project fixtures — DELIBERATE violations, one per project rule.
// Read only by `slop-gate --self-test` (fixturesDirs); never in scan roots, never compiled.
import { DialogContent } from '@zync/ui'; // zync-ui-flat-import
import { Table } from '@zync/ui'; // no-zync-ui-table-primitive
import { db } from '@zync/db'; // no-raw-drizzle-route

export const colors = { accent: '#ff0044', alt: 'rgb(1, 2, 3)' }; // oklch-only

export function CanaryTable() {
  // no-html-table
  return (
    <table className="x">
      <tr>
        <td>x</td>
      </tr>
    </table>
  );
}

export function canaryNeon(sqlClient: { query: (s: string) => unknown }) {
  return sqlClient.query('select 1'); // neon-dot-query (ast)
}

export const unused = { DialogContent, Table, db };
```

- [ ] **Step 2: Verify the ast rule fires on the fixture**

Run: `cd /home/user/Projects/zync.is && ast-grep scan --rule .slop-gate/rules/ast/neon-dot-query.yml .slop-gate/fixtures/src/canary.tsx`
Expected: one match reported on the `sqlClient.query('select 1')` line (rule `neon-dot-query`). If `ast-grep scan --rule` flag spelling differs in the installed version, use the engine path instead:
`node --input-type=module -e "import { resolveConfig } from '/home/user/Projects/slop-gate/src/config.mjs'; import { runAstGrepScan } from '/home/user/Projects/slop-gate/src/ast-engine.mjs'; const c = await resolveConfig('/home/user/Projects/zync.is/.slop-gate/config.mjs'); const r = runAstGrepScan(c, ['.slop-gate/fixtures/src/canary.tsx'], { rawTargets: true }); console.log(r.violations.map(v => v.id));"`
Expected output array includes `'neon-dot-query'`.

- [ ] **Step 3: No commit yet**

Commit happens in Task 4 together with the config change (self-test only goes green when both land — committing the fixture alone is fine but the config commit is the meaningful checkpoint; single commit keeps `.slop-gate/` atomic).

---

### Task 4: Zync — config repair, tsc enablement, verification, conditional baseline (spec 3.1, 3.6)

**Wave:** 2
**Blocks:** Task 5
**Blocked by:** Task 1, Task 2, Task 3

**Files:**
- Modify: `/home/user/Projects/zync.is/.slop-gate/config.mjs`
- Maybe create: `/home/user/Projects/zync.is/.slop-gate/baseline.json` (only if pre-existing tsc errors)

ORDER MATTERS inside this task: once `checkers.tsc` is enabled, every commit-tier gate run (including the Claude-Code commit hook on our own commits) executes tsc over all three apps. If pre-existing type errors exist, commits are blocked until the ratchet baseline absorbs them — so the baseline decision MUST happen before the commit step.

- [ ] **Step 1: Rewrite `.slop-gate/config.mjs`**

Replace in full (changes vs current: `.slop-gate/fixtures/src` removed from `roots`; `checkers.tsc` added; everything else byte-identical):

```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', '.slop-gate'],
  baseline: ['no-stubs', 'ts-suppress', 'as-any', 'kv-ban'],
  rules: ['./rules/zync-patterns.mjs'],
  astRules: './rules/ast',
  checkers: {
    tsc: {
      tsconfig: ['apps/zync-app/tsconfig.json', 'apps/zync-www/tsconfig.json', 'apps/zync-admin/tsconfig.json'],
      timeout: 180,
    },
  },
  gate: { file: ['critical', 'high'], staged: ['critical', 'high'] },
  suppressions: './suppressions.json',
  fixtures: './fixtures',
};
```

All three tsconfigs verified present (2026-06-10): `apps/zync-app/tsconfig.json`, `apps/zync-www/tsconfig.json`, `apps/zync-admin/tsconfig.json`. tsc binary: no local `node_modules/.bin/tsc`; PATH fallback resolves `/usr/local/bin/tsc` (Task 1). `timeout: 180` is per tsconfig entry.

- [ ] **Step 2: Self-test must list the project ast rule**

Run: `cd /home/user/Projects/zync.is && node /home/user/Projects/slop-gate/bin/slop-gate --self-test --config .slop-gate/config.mjs; echo "exit=$?"`
Expected: stderr contains `OK neon-dot-query`-style line — exactly: `OK ast neon-dot-query` — plus the 9 regex `OK` lines, engine canary OK, 5 parser OK lines, **no `FAIL config:` lines** (fixtures dir now exists, dangling root removed), `exit=0`.

- [ ] **Step 3: Full snapshot scan — zero notices, record tsc error count**

Run:

```bash
cd /home/user/Projects/zync.is && node --input-type=module -e "
import { resolveConfig } from '/home/user/Projects/slop-gate/src/config.mjs';
import { collectViolations, applyGateFilters } from '/home/user/Projects/slop-gate/src/gate.mjs';
const config = await resolveConfig('.slop-gate/config.mjs');
const { violations, notices } = collectViolations('full', config, 'commit');
const gated = applyGateFilters(violations, config, 'staged');
console.log('notices:', JSON.stringify(notices));
console.log('gated total:', gated.length);
const tsc = gated.filter(v => v.id.startsWith('tsc-'));
console.log('tsc violations:', tsc.length);
for (const v of tsc.slice(0, 20)) console.log(' ', v.file + ':' + v.line, v.id, v.text);
"
```

Expected: `notices: []` (no ast abort — F1 fixed, tsc available — F4 fixed). `gated total` = `tsc violations` count (regex scan was 0 at design time). Record the tsc count for Step 4. If notices mention `tsc(...) failed: killed by signal` → timeout; raise `timeout` in config and re-run. If a tsconfig produces spurious module-resolution errors (global tsc vs workspace TS version), record the exact errors — they are still real gate output; the baseline absorbs them, and version pinning can be revisited later. Astro app caveat: `apps/zync-www` is Astro — if its tsconfig errors on `.astro` imports under plain tsc, that surfaces here too; same treatment (baseline) — do NOT silently drop the entry from the array without recording why.

- [ ] **Step 4: Conditional ratchet baseline (spec 3.6)**

If Step 3 reported `tsc violations: 0` → skip this step entirely (no baseline file; zero-tolerance from a green state).
If > 0:

```bash
cd /home/user/Projects/zync.is && node /home/user/Projects/slop-gate/bin/slop-gate baseline --config .slop-gate/config.mjs
```

Expected: `slop-gate: baseline written — N entries → /home/user/Projects/zync.is/.slop-gate/baseline.json` with N == the Step 3 gated total. From here, only NEW violations (by fingerprint) block.

- [ ] **Step 5: Commit (zync repo)**

```bash
cd /home/user/Projects/zync.is
git add .slop-gate/config.mjs .slop-gate/fixtures/src/canary.tsx
git add .slop-gate/baseline.json 2>/dev/null || true   # only exists if Step 4 ran
git commit -m "fix(slop-gate): repair fixtures root, prove ast rules, enable monorepo tsc checker"
```

This commit itself passes through the Claude-Code commit hook → staged gate with the new config; it must pass (baseline already absorbs any pre-existing tsc errors). If it blocks, the output names what's new — fix that, don't suppress.

---

### Task 5: Zync — git-level pre-commit hook + bypass-closure proof (spec 3.5, F3)

**Wave:** 3
**Blocks:** Task 6
**Blocked by:** Task 4

**Files:**
- Create (machine-local, NOT committed): `/home/user/Projects/zync.is/.git/hooks/pre-commit`

This closes the F3 bypass: cursor-agent and plain-shell `git commit` currently skip the gate entirely (only Claude-Code-session commits are gated). `install-hooks` writes a marker-managed block (`# slop-gate-hook v1 BEGIN/END`) into `.git/hooks/pre-commit`.

- [ ] **Step 1: Install**

Run: `cd /home/user/Projects/zync.is && node /home/user/Projects/slop-gate/bin/slop-gate install-hooks --config .slop-gate/config.mjs`
Expected: `slop-gate: pre-commit hook created (/home/user/Projects/zync.is/.git/hooks/pre-commit)` (or `updated` if a hook exists).

- [ ] **Step 2: Verify hook content and mode**

Run: `cd /home/user/Projects/zync.is && cat .git/hooks/pre-commit && stat -c '%a' .git/hooks/pre-commit`
Expected: file contains `# slop-gate-hook v1 BEGIN` and `# slop-gate-hook v1 END` wrapping a `node /home/user/Projects/slop-gate/bin/slop-gate --staged --config ...` line; mode `755`.

- [ ] **Step 3: Prove a planted violation is blocked at the git layer**

Do NOT use `git commit` for this proof — the Claude-Code session's own PreToolUse hook would intercept first and prove nothing about the git hook. Execute the hook directly against a staged violation:

```bash
cd /home/user/Projects/zync.is
printf 'export const probe = (globalThis as any).probe;\n' > apps/zync-app/src/__slopgate_probe.ts
git add apps/zync-app/src/__slopgate_probe.ts
bash .git/hooks/pre-commit; echo "hook_exit=$?"
git reset -q HEAD apps/zync-app/src/__slopgate_probe.ts
rm apps/zync-app/src/__slopgate_probe.ts
```

Expected: gate output flagging the `as any` (baseline pack `as-any`, gated severity) in `__slopgate_probe.ts`, and `hook_exit=1` (nonzero). After cleanup, `git status` shows no probe remnants.

- [ ] **Step 4: Verify a clean staged set passes the hook**

Run: `cd /home/user/Projects/zync.is && bash .git/hooks/pre-commit; echo "hook_exit=$?"`
(Nothing staged.) Expected: `hook_exit=0`.

- [ ] **Step 5: Worktree coverage check**

`git worktree`-created checkouts share the main repo's `.git/hooks` via the common git dir — covered automatically. Directories under `.claude/worktrees/` may instead be independent clones; check:

```bash
cd /home/user/Projects/zync.is
git worktree list
for d in .claude/worktrees/*/; do
  [ -d "$d" ] || continue
  echo "== $d"; git -C "$d" rev-parse --git-common-dir 2>/dev/null || echo "not a git checkout"
done
```

For each entry whose `--git-common-dir` resolves to `/home/user/Projects/zync.is/.git` (or a path under it): covered, nothing to do. For any independent clone (its own `.git` dir): run the same `install-hooks` command from Step 1 inside that clone with `--config <clone>/.slop-gate/config.mjs`. If `.claude/worktrees/` is empty or has no git checkouts: nothing to do — the doc note in Task 6 covers future ones.

No commit in this task — the hook file lives under `.git/` (never tracked).

---

### Task 6: Docs reconciliation + final commit (spec §4)

**Wave:** 4
**Blocks:** —
**Blocked by:** Task 5

**Files:**
- Modify: `/home/user/Projects/zync.is/.claude/skills/zc-orchestrate/SKILL.md`
- Modify: `/home/user/Projects/zync.is/docs/specs/2026-06-10-slop-gate-global-architecture-design.md`
- Modify: `/home/user/Projects/zync.is/docs/specs/2026-06-10-slop-gate-zync-hardening-design.md`

- [ ] **Step 1: Document hook re-install for fresh clones/worktrees**

In `/home/user/Projects/zync.is/.claude/skills/zc-orchestrate/SKILL.md` (file exists; if its structure has a setup/environment section, append there, else append at end):

```markdown
## Slop-gate git hook (per-clone setup)

The slop-gate git pre-commit hook is machine-local (`.git/hooks/pre-commit`, never committed).
`git worktree` checkouts inherit it via the shared common git dir. Any INDEPENDENT clone
(e.g. a fresh clone under `.claude/worktrees/`) must re-install it before committing:

​```bash
node /home/user/Projects/slop-gate/bin/slop-gate install-hooks --config .slop-gate/config.mjs
​```

Without this, commits from that clone bypass the gate (the original F3 defect).
```

(Strip the zero-width characters before the inner backticks — they exist only to nest the code fence in this plan.)

- [ ] **Step 2: One-line note in the global architecture spec**

In `/home/user/Projects/zync.is/docs/specs/2026-06-10-slop-gate-global-architecture-design.md`, append at the end of the document:

```markdown
> **2026-06-10 amendment** (per `2026-06-10-slop-gate-zync-hardening-design.md`): the tsc
> checker accepts `tsconfig: string | string[]` (monorepo) with PATH-tsc fallback, and the
> engine self-test now asserts every *project* ast rule fires on the project fixtures and
> FAILs on dangling `roots`/`fixtures` paths.
```

- [ ] **Step 3: Flip the hardening spec status**

In `/home/user/Projects/zync.is/docs/specs/2026-06-10-slop-gate-zync-hardening-design.md`, change `**Status:** Design` → `**Status:** Implemented (2026-06-10)`. If Task 4 Step 4 created a baseline, append the entry count and reason on the same line, e.g. `**Status:** Implemented (2026-06-10) — tsc baseline absorbed N pre-existing errors`. If tsc was green, append `— tsc green, no baseline`.

- [ ] **Step 4: Final verification sweep**

```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=$?"
bash .git/hooks/pre-commit; echo "hook=$?"
```

Expected: `selftest=0` (including `OK ast neon-dot-query`), `hook=0`.

- [ ] **Step 5: Commit (zync repo)**

```bash
cd /home/user/Projects/zync.is
git add .claude/skills/zc-orchestrate/SKILL.md \
        docs/specs/2026-06-10-slop-gate-global-architecture-design.md \
        docs/specs/2026-06-10-slop-gate-zync-hardening-design.md \
        docs/plans/2026-06-10-slop-gate-zync-hardening.md
git commit -m "docs(slop-gate): zync hardening spec/plan + hook reinstall note + engine surface amendment"
```

---

## Acceptance (maps to spec §Testing)

| Spec check | Where proven |
|---|---|
| Engine: tsc-array + PATH-fallback detect unit tests | Task 1 Steps 1–4 |
| Engine: selftest assertion catches non-firing project yml (exit 1) | Task 2 Step 1 (`never-fires.yml` case) |
| zync: self-test lists `neon-dot-query` OK | Task 4 Step 2 |
| zync: full scan 0 violations, 0 notices | Task 4 Step 3 (`notices: []`; gated = tsc-only, absorbed by baseline if any) |
| zync: plain-shell commit of planted `as any` blocked | Task 5 Step 3 (direct hook execution) |
| zync: tsc checker fires on a planted type error | Implicitly proven by Task 4 Step 3 if pre-existing errors > 0; if tsc was green, prove once: add `const n: number = 'x';` to `apps/zync-app/src/__slopgate_probe.ts`, stage it, run `bash .git/hooks/pre-commit` → expect `tsc-TS2322` block, then clean up exactly as in Task 5 Step 3 |
