# /regression Skill Implementation Plan

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

**Goal:** Build the `/regression` Claude Code skill — one invocation that runs the project regression sweep at two tiers and reports a canonical status line plus a machine GREEN/RED verdict.

**Architecture:** A skill directory `.claude/skills/regression/` with three files: `SKILL.md` (the skill doc), `run.mjs` (a Node ESM orchestrator split into exported pure functions + a `main` orchestrator), and `run.test.mjs` (co-located `node:test` tests). The pure layer (parsers, verdict, formatting, platform fork) is unit-tested TDD-style; the I/O layer (`runCheck`, `main`) is subprocess-tested for the argument-error path and covered by functional verification for real tool runs.

**Tech Stack:** Node ≥20 ESM (`node:child_process`, `node:fs`, `node:path`, `node:process`, `node:url`), built-in `node:test` + `node:assert/strict`. Zero npm dependencies. Wraps existing npm/composer scripts and the `bin/gitleaks` + `bin/lychee` binaries.

**Source spec:** `docs/superpowers/specs/2026-05-16-regression-skill-design.md` (commits `5e6b1b6`, `e822925`).

**Conventions for the executing engineer:**

- The skill `.mjs` files are plain Node ESM, like `tools/*.mjs`. They are NOT covered by the project's ESLint config (which globs `app/resources/js/**`), so no ESLint gate applies to them.
- `lefthook` pre-commit on a `.mjs`-only commit runs **only gitleaks** (markdownlint/cspell glob `*.md`; pint/larastan/eslint/squawk/stylelint do not match). The `SKILL.md` commit additionally triggers markdownlint + cspell.
- Test runner: `node --test .claude/skills/regression/run.test.mjs` (Node ≥20, no deps). Run it from the repo root.
- The reference pattern for a tested `.mjs` is `tools/ruflo-queen-hook.test.mjs` (exported pure functions unit-tested + `main` subprocess-tested via `execFileSync`).
- Commit messages end with the trailer `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`.

---

## File Structure

| File | Responsibility |
|---|---|
| `.claude/skills/regression/run.mjs` | Orchestrator. Exports pure functions: `resolveBinary`, `buildHeader`, `parseExit`, `parsePest`, `parseVitest`, `parseViteBuild`, `parseLarastan`, `parseGitleaks`, `parseLychee`, `computeVerdict`, `buildCanonicalLine`, `formatRow`, `verdictLine`, the `CHECKS` registry, and `main`. Internal `runCheck` does the `spawnSync` I/O. Runs `main` only when executed directly (guarded by `import.meta.url`). |
| `.claude/skills/regression/run.test.mjs` | Co-located `node:test` suite. Unit-tests every pure function on fixture strings; subprocess-tests `main` for the unknown-argument path. |
| `.claude/skills/regression/SKILL.md` | The skill document — frontmatter (`name`, `description`), "Когда использовать", "Workflow", "Уровни", "Правила инвокации (self-restraint)", "Caveats", "Не использовать когда". |

Tasks 1–7 build `run.mjs` + `run.test.mjs` incrementally (one pure-function group per task, TDD). Task 8 writes `SKILL.md`. Task 9 is functional verification.

---

### Task 1: Scaffold + `resolveBinary` + `buildHeader` + `parseExit`

Bootstrap the skill directory and the three smallest pure helpers: platform binary resolution, the output header line, and the exit-code token.

**Files:**

- Create: `.claude/skills/regression/run.mjs`
- Create: `.claude/skills/regression/run.test.mjs`

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

Create `.claude/skills/regression/run.test.mjs`:

```javascript
import test from 'node:test';
import assert from 'node:assert/strict';
import {
  resolveBinary, buildHeader, parseExit,
} from './run.mjs';

test('resolveBinary: win32 → bin\\<name>.exe', () => {
  assert.equal(resolveBinary('gitleaks', 'win32'), 'bin\\gitleaks.exe');
});
test('resolveBinary: non-win32 → bare name on PATH', () => {
  assert.equal(resolveBinary('lychee', 'linux'), 'lychee');
  assert.equal(resolveBinary('lychee', 'darwin'), 'lychee');
});

test('buildHeader: starts with the tier banner', () => {
  assert.ok(buildHeader('quick').startsWith('─ /regression quick '));
  assert.ok(buildHeader('full').startsWith('─ /regression full '));
});
test('buildHeader: is padded with dashes', () => {
  assert.ok(buildHeader('full').length >= 30);
});

test('parseExit: builds "<label> <code>" token', () => {
  assert.equal(parseExit('Pint', 0), 'Pint 0');
  assert.equal(parseExit('ESLint', 1), 'ESLint 1');
});
```

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

Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: FAIL — `Cannot find module ... run.mjs` (the module does not exist yet).

- [ ] **Step 3: Write minimal implementation**

Create `.claude/skills/regression/run.mjs`:

```javascript
#!/usr/bin/env node
// .claude/skills/regression/run.mjs
// Regression sweep orchestrator for the /regression skill.
// Design: docs/superpowers/specs/2026-05-16-regression-skill-design.md
import process from 'node:process';

// ── pure: platform binary resolution ───────────────────────────────
export function resolveBinary(name, platform = process.platform) {
  return platform === 'win32' ? `bin\\${name}.exe` : name;
}

// ── pure: output header line ───────────────────────────────────────
export function buildHeader(tier) {
  const head = `─ /regression ${tier} `;
  return head + '─'.repeat(Math.max(3, 48 - head.length));
}

// ── pure: exit-code token ──────────────────────────────────────────
export function parseExit(label, code) {
  return `${label} ${code}`;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: PASS — 5 tests pass, 0 fail.

- [ ] **Step 5: Commit**

```bash
git add .claude/skills/regression/run.mjs .claude/skills/regression/run.test.mjs
git commit -m "feat(regression): skill scaffold + resolveBinary/buildHeader/parseExit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

(`lefthook` runs only gitleaks on this `.mjs`-only commit — expect "no leaks found".)

---

### Task 2: Test-count parsers — `parsePest` + `parseVitest`

Two parsers that turn raw Pest/Vitest summary output into canonical count tokens.

**Files:**

- Modify: `.claude/skills/regression/run.mjs`
- Modify: `.claude/skills/regression/run.test.mjs`

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

Append to `.claude/skills/regression/run.test.mjs` (and add `parsePest, parseVitest` to the import from `./run.mjs`):

```javascript
test('parsePest: passed + skipped, no failures → total derived', () => {
  const out = '  Tests:    3 skipped, 739 passed (2104 assertions)\n  Duration: 71.23s';
  assert.equal(parsePest(out), 'Pest 742/739/3sk/0');
});
test('parsePest: with failures', () => {
  const out = '  Tests:    2 failed, 1 skipped, 736 passed (2090 assertions)';
  assert.equal(parsePest(out), 'Pest 739/736/1sk/2');
});
test('parsePest: passed only → zeros for skipped/failed', () => {
  assert.equal(parsePest('  Tests:    19 passed (44 assertions)'), 'Pest 19/19/0sk/0');
});

test('parseVitest: files + passed + skipped', () => {
  const out = ' Test Files  92 passed (92)\n      Tests  774 passed | 3 skipped (777)\n   Duration  12.6s';
  assert.equal(parseVitest(out), 'Vitest 92f/774/3sk/0');
});
test('parseVitest: with failures, does not confuse "Test Files" with "Tests"', () => {
  const out = ' Test Files  2 failed | 90 passed (92)\n      Tests  5 failed | 769 passed (774)';
  assert.equal(parseVitest(out), 'Vitest 90f/769/0sk/5');
});
```

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

Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: FAIL — `parsePest is not a function` / import error for the new names.

- [ ] **Step 3: Write minimal implementation**

Add to `.claude/skills/regression/run.mjs` after `parseExit`:

```javascript
// ── pure: test-count parsers ───────────────────────────────────────
export function parsePest(stdout) {
  const passed = Number(stdout.match(/(\d+)\s+passed/)?.[1] ?? 0);
  const skipped = Number(stdout.match(/(\d+)\s+skipped/)?.[1] ?? 0);
  const failed = Number(stdout.match(/(\d+)\s+failed/)?.[1] ?? 0);
  return `Pest ${passed + skipped + failed}/${passed}/${skipped}sk/${failed}`;
}

export function parseVitest(stdout) {
  const files = Number(stdout.match(/Test Files\s+(\d+)\s+passed/)?.[1] ?? 0);
  const line = stdout.match(/^\s*Tests\s+.+$/m)?.[0] ?? '';
  const passed = Number(line.match(/(\d+)\s+passed/)?.[1] ?? 0);
  const skipped = Number(line.match(/(\d+)\s+skipped/)?.[1] ?? 0);
  const failed = Number(line.match(/(\d+)\s+failed/)?.[1] ?? 0);
  return `Vitest ${files}f/${passed}/${skipped}sk/${failed}`;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: PASS — 10 tests pass total (5 from Task 1 + 5 new).

- [ ] **Step 5: Commit**

```bash
git add .claude/skills/regression/run.mjs .claude/skills/regression/run.test.mjs
git commit -m "feat(regression): Pest + Vitest count parsers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 3: Content parsers — `parseViteBuild` + `parseLarastan` + `parseGitleaks` + `parseLychee`

The remaining four parsers: Vite build time, Larastan error count, gitleaks leaks/commits, lychee OK/errors.

**Files:**

- Modify: `.claude/skills/regression/run.mjs`
- Modify: `.claude/skills/regression/run.test.mjs`

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

Append to `.claude/skills/regression/run.test.mjs` (add `parseViteBuild, parseLarastan, parseGitleaks, parseLychee` to the import):

```javascript
test('parseViteBuild: extracts build time', () => {
  assert.equal(parseViteBuild('✓ 312 modules transformed.\n✓ built in 2.03s'), 'Vite build 2.03s');
});
test('parseViteBuild: no match → "?"', () => {
  assert.equal(parseViteBuild('build crashed'), 'Vite build ?s');
});

test('parseLarastan: clean → 0', () => {
  assert.equal(parseLarastan(' [OK] No errors'), 'Larastan 0');
});
test('parseLarastan: counts errors', () => {
  assert.equal(parseLarastan(' [ERROR] Found 2 errors'), 'Larastan 2');
});

test('parseGitleaks: clean → 0 leaks', () => {
  const out = 'INF 442 commits scanned.\nINF no leaks found';
  assert.equal(parseGitleaks(out, 0), 'gitleaks 0/442');
});
test('parseGitleaks: leaks found (non-zero exit)', () => {
  const out = 'INF 442 commits scanned.\nWRN 3 leaks found';
  assert.equal(parseGitleaks(out, 1), 'gitleaks 3/442');
});

test('parseLychee: OK + errors', () => {
  const out = '🔍 325 Total (in 9s)\n✅ 325 OK\n🚫 0 Errors';
  assert.equal(parseLychee(out), 'lychee 325/0');
});
test('parseLychee: with broken links', () => {
  const out = '🔍 327 Total\n✅ 325 OK\n🚫 2 Errors';
  assert.equal(parseLychee(out), 'lychee 325/2');
});
```

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

Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: FAIL — import error / `parseViteBuild is not a function`.

- [ ] **Step 3: Write minimal implementation**

Add to `.claude/skills/regression/run.mjs` after `parseVitest`:

```javascript
// ── pure: content parsers ──────────────────────────────────────────
export function parseViteBuild(stdout) {
  const m = stdout.match(/built in ([\d.]+)\s*s/i);
  return `Vite build ${m ? m[1] : '?'}s`;
}

export function parseLarastan(stdout) {
  const m = stdout.match(/Found (\d+) error/i);
  return `Larastan ${m ? m[1] : 0}`;
}

export function parseGitleaks(stdout, code) {
  const commits = stdout.match(/(\d+)\s+commits?\s+scanned/i)?.[1] ?? '?';
  const leaks = code === 0
    ? '0'
    : (stdout.match(/(\d+)\s+leaks?\s+found/i)?.[1]
       ?? stdout.match(/leaks?\s+found:?\s*(\d+)/i)?.[1]
       ?? '≥1');
  return `gitleaks ${leaks}/${commits}`;
}

export function parseLychee(stdout) {
  const ok = stdout.match(/(\d+)\s+OK/)?.[1] ?? '?';
  const errors = stdout.match(/(\d+)\s+Errors?/i)?.[1] ?? '0';
  return `lychee ${ok}/${errors}`;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: PASS — 18 tests pass total.

- [ ] **Step 5: Commit**

```bash
git add .claude/skills/regression/run.mjs .claude/skills/regression/run.test.mjs
git commit -m "feat(regression): Vite build / Larastan / gitleaks / lychee parsers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 4: Verdict logic — `computeVerdict`

Turn an array of check results into a GREEN / RED / RED-INCOMPLETE verdict and an exit code.

**Files:**

- Modify: `.claude/skills/regression/run.mjs`
- Modify: `.claude/skills/regression/run.test.mjs`

A result object has the shape `{ id, label, code, skipped, ms, token }`. `computeVerdict` reads only `label`, `code`, `skipped`.

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

Append to `.claude/skills/regression/run.test.mjs` (add `computeVerdict` to the import):

```javascript
test('computeVerdict: all exit 0 → GREEN, exit code 0', () => {
  const v = computeVerdict([
    { label: 'Pint', code: 0, skipped: false },
    { label: 'ESLint', code: 0, skipped: false },
  ]);
  assert.equal(v.verdict, 'GREEN');
  assert.equal(v.exitCode, 0);
  assert.deepEqual(v.failed, []);
});
test('computeVerdict: one non-zero exit → RED, exit code 1', () => {
  const v = computeVerdict([
    { label: 'Pint', code: 0, skipped: false },
    { label: 'Larastan', code: 1, skipped: false },
  ]);
  assert.equal(v.verdict, 'RED');
  assert.equal(v.exitCode, 1);
  assert.deepEqual(v.failed, ['Larastan']);
});
test('computeVerdict: a skipped check → RED-INCOMPLETE', () => {
  const v = computeVerdict([
    { label: 'Pint', code: 0, skipped: false },
    { label: 'gitleaks', code: null, skipped: true },
  ]);
  assert.equal(v.verdict, 'RED-INCOMPLETE');
  assert.equal(v.exitCode, 1);
  assert.deepEqual(v.skipped, ['gitleaks']);
});
test('computeVerdict: skipped takes precedence over a failure', () => {
  const v = computeVerdict([
    { label: 'Larastan', code: 1, skipped: false },
    { label: 'lychee', code: null, skipped: true },
  ]);
  assert.equal(v.verdict, 'RED-INCOMPLETE');
  assert.deepEqual(v.failed, ['Larastan']);
  assert.deepEqual(v.skipped, ['lychee']);
});
```

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

Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: FAIL — `computeVerdict is not a function`.

- [ ] **Step 3: Write minimal implementation**

Add to `.claude/skills/regression/run.mjs` after `parseLychee`:

```javascript
// ── pure: verdict ──────────────────────────────────────────────────
export function computeVerdict(results) {
  const skipped = results.filter((r) => r.skipped).map((r) => r.label);
  const failed = results
    .filter((r) => !r.skipped && r.code !== 0)
    .map((r) => r.label);
  if (skipped.length) return { verdict: 'RED-INCOMPLETE', exitCode: 1, failed, skipped };
  if (failed.length) return { verdict: 'RED', exitCode: 1, failed, skipped };
  return { verdict: 'GREEN', exitCode: 0, failed, skipped };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: PASS — 22 tests pass total.

- [ ] **Step 5: Commit**

```bash
git add .claude/skills/regression/run.mjs .claude/skills/regression/run.test.mjs
git commit -m "feat(regression): GREEN/RED/RED-INCOMPLETE verdict logic

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 5: Output formatting — `buildCanonicalLine` + `formatRow` + `verdictLine`

Three pure formatters: the canonical status line, a per-check table row, and the verdict line.

**Files:**

- Modify: `.claude/skills/regression/run.mjs`
- Modify: `.claude/skills/regression/run.test.mjs`

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

Append to `.claude/skills/regression/run.test.mjs` (add `buildCanonicalLine, formatRow, verdictLine` to the import):

```javascript
test('buildCanonicalLine: joins tokens in result order with " / "', () => {
  const results = [
    { token: 'Pint 0' }, { token: 'ESLint 0' }, { token: 'Pest 742/739/3sk/0' },
  ];
  assert.equal(buildCanonicalLine(results), 'Pint 0 / ESLint 0 / Pest 742/739/3sk/0');
});

test('formatRow: passed check → ✅ mark, label, code, time', () => {
  const row = formatRow({ label: 'Pint', code: 0, ms: 1800, skipped: false });
  assert.ok(row.startsWith('[✅] Pint'));
  assert.ok(row.includes('1.8s'));
});
test('formatRow: failed check → ❌ mark', () => {
  assert.ok(formatRow({ label: 'Larastan', code: 1, ms: 8400, skipped: false }).startsWith('[❌] Larastan'));
});
test('formatRow: skipped check → ⚠ mark + SKIPPED', () => {
  const row = formatRow({ label: 'gitleaks', code: null, ms: 0, skipped: true });
  assert.ok(row.startsWith('[⚠] gitleaks'));
  assert.ok(row.includes('SKIPPED'));
});

test('verdictLine: GREEN', () => {
  const line = verdictLine({ verdict: 'GREEN', failed: [], skipped: [] }, 12);
  assert.ok(line.includes('🟢 GREEN'));
  assert.ok(line.includes('12'));
});
test('verdictLine: RED lists failed checks', () => {
  const line = verdictLine({ verdict: 'RED', failed: ['Larastan'], skipped: [] }, 12);
  assert.ok(line.includes('🔴 RED'));
  assert.ok(line.includes('Larastan'));
});
test('verdictLine: RED-INCOMPLETE lists skipped checks', () => {
  const line = verdictLine({ verdict: 'RED-INCOMPLETE', failed: [], skipped: ['gitleaks'] }, 12);
  assert.ok(line.includes('🟠 RED-INCOMPLETE'));
  assert.ok(line.includes('gitleaks'));
});
```

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

Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: FAIL — `buildCanonicalLine is not a function`.

- [ ] **Step 3: Write minimal implementation**

Add to `.claude/skills/regression/run.mjs` after `computeVerdict`:

```javascript
// ── pure: output formatting ────────────────────────────────────────
export function buildCanonicalLine(results) {
  return results.map((r) => r.token).join(' / ');
}

export function formatRow(r) {
  const mark = r.skipped ? '⚠' : r.code === 0 ? '✅' : '❌';
  const label = r.label.padEnd(14);
  const status = r.skipped
    ? 'SKIPPED — binary not found'
    : `${r.code}   ${(r.ms / 1000).toFixed(1)}s`;
  return `[${mark}] ${label}${status}`;
}

export function verdictLine(v, total) {
  if (v.verdict === 'GREEN') {
    return `🟢 GREEN — все ${total} проверок passed`;
  }
  if (v.verdict === 'RED-INCOMPLETE') {
    const tail = v.failed.length ? `; провал: ${v.failed.join(', ')}` : '';
    return `🟠 RED-INCOMPLETE — не прогналось: ${v.skipped.join(', ')}${tail}`;
  }
  return `🔴 RED — ${v.failed.length}/${total} failed: ${v.failed.join(', ')}`;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: PASS — 29 tests pass total.

- [ ] **Step 5: Commit**

```bash
git add .claude/skills/regression/run.mjs .claude/skills/regression/run.test.mjs
git commit -m "feat(regression): canonical line / row / verdict formatters

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 6: `CHECKS` registry

The data table of all 12 checks: id, label, tiers, working directory, command, parser.

**Files:**

- Modify: `.claude/skills/regression/run.mjs`
- Modify: `.claude/skills/regression/run.test.mjs`

Each check is either **script-based** (has `cmd` — an npm/composer script line) or **binary-based** (has `bin` + `argv` — a `bin/` executable). `cwd` is `'.'` for repo-root checks or `'app'` for app-level checks. `parse` receives `(combinedOutput, exitCode)`.

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

Append to `.claude/skills/regression/run.test.mjs` (add `CHECKS` to the import):

```javascript
test('CHECKS: quick tier has exactly 6 checks', () => {
  assert.equal(CHECKS.filter((c) => c.tiers.includes('quick')).length, 6);
});
test('CHECKS: full tier has exactly 12 checks', () => {
  assert.equal(CHECKS.filter((c) => c.tiers.includes('full')).length, 12);
});
test('CHECKS: quick is a strict subset of full', () => {
  const full = new Set(CHECKS.filter((c) => c.tiers.includes('full')).map((c) => c.id));
  for (const c of CHECKS.filter((c) => c.tiers.includes('quick'))) {
    assert.ok(full.has(c.id), `${c.id} in quick must also be in full`);
  }
});
test('CHECKS: every check has id, label, cwd, parse, and a command source', () => {
  for (const c of CHECKS) {
    assert.ok(c.id && c.label && c.cwd, `${c.id}: id/label/cwd`);
    assert.equal(typeof c.parse, 'function', `${c.id}: parse is a function`);
    assert.ok(c.cmd || (c.bin && Array.isArray(c.argv)), `${c.id}: has cmd or bin+argv`);
  }
});
test('CHECKS: ids are unique', () => {
  assert.equal(new Set(CHECKS.map((c) => c.id)).size, CHECKS.length);
});
```

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

Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: FAIL — `CHECKS` is undefined / not exported.

- [ ] **Step 3: Write minimal implementation**

Add to `.claude/skills/regression/run.mjs` after `verdictLine`:

```javascript
// ── data: checks registry ──────────────────────────────────────────
// Script-based checks carry `cmd`; binary-based checks carry `bin` + `argv`.
// `parse(combinedOutput, exitCode)` → canonical token. `cwd`: '.' = repo root,
// 'app' = the Laravel app. Execution order: quick checks first, then heavy.
export const CHECKS = [
  {
    id: 'pint', label: 'Pint', tiers: ['quick', 'full'], cwd: 'app',
    cmd: 'composer pint:test', parse: (_o, c) => parseExit('Pint', c),
  },
  {
    id: 'eslint', label: 'ESLint', tiers: ['quick', 'full'], cwd: 'app',
    cmd: 'npm run lint:vue', parse: (_o, c) => parseExit('ESLint', c),
  },
  {
    id: 'prettier', label: 'Prettier', tiers: ['quick', 'full'], cwd: 'app',
    cmd: 'npm run format:check', parse: (_o, c) => parseExit('Prettier', c),
  },
  {
    id: 'vue-tsc', label: 'vue-tsc', tiers: ['quick', 'full'], cwd: 'app',
    cmd: 'npm run type-check', parse: (_o, c) => parseExit('vue-tsc', c),
  },
  {
    id: 'markdownlint', label: 'markdownlint', tiers: ['quick', 'full'], cwd: '.',
    cmd: 'npm run lint:md', parse: (_o, c) => parseExit('markdownlint', c),
  },
  {
    id: 'cspell', label: 'cspell', tiers: ['quick', 'full'], cwd: '.',
    cmd: 'npm run spell', parse: (_o, c) => parseExit('cspell', c),
  },
  {
    id: 'larastan', label: 'Larastan', tiers: ['full'], cwd: 'app',
    cmd: 'composer stan', parse: (o) => parseLarastan(o),
  },
  {
    id: 'pest', label: 'Pest', tiers: ['full'], cwd: 'app',
    cmd: 'composer test:parallel', parse: (o) => parsePest(o),
  },
  {
    id: 'vitest', label: 'Vitest', tiers: ['full'], cwd: 'app',
    cmd: 'npm run test:vue', parse: (o) => parseVitest(o),
  },
  {
    id: 'vite-build', label: 'Vite build', tiers: ['full'], cwd: 'app',
    cmd: 'npm run build', parse: (o) => parseViteBuild(o),
  },
  {
    id: 'lychee', label: 'lychee', tiers: ['full'], cwd: '.',
    bin: 'lychee',
    argv: ['--config', '.lychee.toml', 'docs/**/*.md', 'db/**/*.md', '*.md'],
    parse: (o) => parseLychee(o),
  },
  {
    id: 'gitleaks', label: 'gitleaks', tiers: ['full'], cwd: '.',
    bin: 'gitleaks',
    argv: ['detect', '--source', '.', '--no-banner', '--config', '.gitleaks.toml', '--redact'],
    parse: (o, c) => parseGitleaks(o, c),
  },
];
```

- [ ] **Step 4: Run test to verify it passes**

Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: PASS — 34 tests pass total.

- [ ] **Step 5: Commit**

```bash
git add .claude/skills/regression/run.mjs .claude/skills/regression/run.test.mjs
git commit -m "feat(regression): 12-check registry (quick=6, full=12)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 7: I/O layer — `runCheck` + `main` + subprocess test

The orchestrator: `runCheck` spawns one check; `main` parses the tier argument, runs the tier's checks, prints the table / canonical line / verdict, prints failed-check output, and sets the process exit code.

**Files:**

- Modify: `.claude/skills/regression/run.mjs`
- Modify: `.claude/skills/regression/run.test.mjs`

`main` is subprocess-tested only for the **unknown-argument** path (deterministic, fast). Real `quick`/`full` runs are covered by Task 9 functional verification — running them inside `run.test.mjs` would execute the whole sweep on every `node --test`.

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

Append to `.claude/skills/regression/run.test.mjs`. Add these imports at the top of the file (alongside the existing `node:test` import):

```javascript
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const RUN = fileURLToPath(new URL('./run.mjs', import.meta.url));
```

Then append the tests:

```javascript
test('main: unknown argument → exit code 2 + error on stderr', () => {
  try {
    execFileSync(process.execPath, [RUN, 'bogus'], { encoding: 'utf8', stdio: 'pipe' });
    assert.fail('expected non-zero exit');
  } catch (err) {
    assert.equal(err.status, 2);
    assert.match(String(err.stderr), /unknown argument/i);
  }
});
test('main: importing run.mjs does not auto-run the sweep', () => {
  // If the import.meta guard were broken, importing run.mjs at the top of this
  // file would have spawned a full sweep. Reaching this assertion proves it did not.
  assert.ok(true);
});
```

Note: `process` is already available; add `import process from 'node:process';` at the top of the test file if not present.

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

Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: FAIL — `main` is undefined, so `run.mjs bogus` does nothing meaningful / exits 0, and `err.status` is not 2.

- [ ] **Step 3: Write minimal implementation**

Add the I/O imports to the top of `.claude/skills/regression/run.mjs` (alongside `import process`):

```javascript
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
```

Add to the end of `.claude/skills/regression/run.mjs`, after the `CHECKS` registry:

```javascript
// ── I/O: run one check ─────────────────────────────────────────────
function runCheck(check, repoRoot) {
  const cwd = check.cwd === '.' ? repoRoot : path.join(repoRoot, check.cwd);
  const start = Date.now();
  const skippedResult = (reason) => ({
    id: check.id, label: check.label, skipped: true, code: null,
    ms: Date.now() - start, token: `${check.label} SKIPPED`, stdout: '', stderr: reason,
  });

  let command;
  if (check.bin) {
    const bin = resolveBinary(check.bin);
    // bin/ executables: existsSync pre-check on Windows (the project ships
    // bin\gitleaks.exe / bin\lychee.exe; on POSIX they come from PATH).
    if (process.platform === 'win32' && !existsSync(path.join(repoRoot, bin))) {
      return skippedResult(`${bin} not found`);
    }
    command = [bin, ...check.argv].join(' ');
  } else {
    command = check.cmd;
  }

  const res = spawnSync(command, {
    cwd, shell: true, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
  });
  const ms = Date.now() - start;
  // ENOENT (POSIX missing binary) or shell exit 127 (command not found) → SKIPPED.
  if ((res.error && res.error.code === 'ENOENT') || res.status === 127) {
    return skippedResult(`command not found: ${command}`);
  }
  const stdout = res.stdout ?? '';
  const stderr = res.stderr ?? '';
  const code = res.status ?? 1;
  const token = check.parse(`${stdout}\n${stderr}`, code);
  return { id: check.id, label: check.label, skipped: false, code, ms, token, stdout, stderr };
}

// ── orchestrator ───────────────────────────────────────────────────
export function main(argv) {
  const tier = argv[0] ?? 'full';
  if (tier !== 'quick' && tier !== 'full') {
    process.stderr.write(
      `regression: unknown argument "${tier}". Usage: run.mjs [quick|full]\n`,
    );
    process.exitCode = 2;
    return;
  }
  const repoRoot = fileURLToPath(new URL('../../../', import.meta.url));
  const checks = CHECKS.filter((c) => c.tiers.includes(tier));

  process.stdout.write(`${buildHeader(tier)}\n`);
  const results = [];
  for (const check of checks) {
    const r = runCheck(check, repoRoot);
    results.push(r);
    process.stdout.write(`${formatRow(r)}\n`);
  }
  process.stdout.write(`${'─'.repeat(48)}\n`);
  process.stdout.write(`Canonical: ${buildCanonicalLine(results)}\n`);

  const v = computeVerdict(results);
  process.stdout.write(`VERDICT: ${verdictLine(v, results.length)}\n`);

  // Full output of failed checks, so failures are visible with file:line.
  // (Refines spec §7 "выше таблицы" → printed after the verdict, to keep the
  // live per-check table readable during a multi-minute run.)
  for (const r of results) {
    if (!r.skipped && r.code !== 0) {
      process.stdout.write(`\n── ${r.label} output ──\n${r.stdout}\n${r.stderr}\n`);
    }
  }
  process.exitCode = v.exitCode;
}

// Run main only when executed directly (not when imported by run.test.mjs).
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
  main(process.argv.slice(2));
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: PASS — 36 tests pass total. The `bogus`-argument subprocess exits 2 with "unknown argument" on stderr.

- [ ] **Step 5: Commit**

```bash
git add .claude/skills/regression/run.mjs .claude/skills/regression/run.test.mjs
git commit -m "feat(regression): runCheck I/O layer + main orchestrator

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 8: `SKILL.md`

The skill document. No TDD — it is documentation. Structure mirrors `.claude/skills/q-item-add/SKILL.md` and `.claude/skills/rls-check/SKILL.md`.

**Files:**

- Create: `.claude/skills/regression/SKILL.md`

- [ ] **Step 1: Write `SKILL.md`**

Create `.claude/skills/regression/SKILL.md` with exactly this content:

````markdown
---
name: regression
description: |
  Run the project regression sweep and report a canonical status line + GREEN/RED verdict.
  Two tiers: `quick` (lint/format/type-check — seconds) and `full` (everything incl.
  Pest --parallel, Larastan, Vitest, Vite build, lychee, gitleaks — minutes).
  Claude auto-runs only `quick` (e.g. during verification-before-completion);
  `full` runs only on explicit `/regression full` or with user confirmation.
---

# Regression — канонический регрессионный свод

## Когда использовать

Перед закрытием задачи/спринта (`full`) или для быстрого фидбэка по ходу работы
(`quick`). Скилл инкапсулирует ~12 команд свода, разбросанных по `package.json`,
`app/package.json`, `app/composer.json` и `lefthook.yml`, в один вызов с
детерминированной канонической строкой и машинным вердиктом.

Invoke via `/regression [quick|full]` (без аргумента → `full`).

## Workflow

1. Определить уровень из аргумента: `quick`, `full`, либо `full` по умолчанию.
2. Запустить через Bash из корня репозитория:

   ```bash
   node .claude/skills/regression/run.mjs <tier>
   ```

3. Показать пользователю полный вывод скрипта (таблица + каноническая строка +
   вердикт + вывод упавших проверок).
4. Интерпретировать вердикт:
   - `GREEN` — свод чист, exit-код 0.
   - `RED` — перечислены упавшие проверки; полный вывод каждой — после вердикта.
   - `RED-INCOMPLETE` — проверка не прогналась (нет бинаря); свод неполон,
     зелёным признать нельзя.

## Уровни

- **`quick`** (6 проверок, секунды): Pint, ESLint, Prettier, vue-tsc,
  markdownlint, cspell.
- **`full`** (12 проверок, минуты): всё из `quick` + Larastan, Pest `--parallel`,
  Vitest, Vite build, lychee, gitleaks.

## Правила инвокации (self-restraint)

- Claude **авто-запускает только `quick`** — в том числе в рамках
  `superpowers:verification-before-completion` перед claim «готово» / «passed» /
  «closed».
- `full` Claude **сам не запускает** — только по явному `/regression full` от
  пользователя ИЛИ запросив подтверждение («запускаю полный свод, ~5–10 мин — ок?»).
- Скилл **не правит `CLAUDE.md`** — он только печатает каноническую строку в
  stdout; вставка строки в `CLAUDE.md` — отдельно, через канал
  `claude-md-management` (`CLAUDE.md` §5 п.10).

## Caveats

- **Pest `--parallel` flake (квирки 72/73/77).** Если Pest показал 1–3 ошибки,
  похожие на Redis-race / cumulative-state / unique-key-collision, — перепрогнать
  `full` один раз ИЛИ свериться с агентом `pest-parallel-debugger` до объявления
  реального RED.
- **ruflo daemon (квирк 93).** Перед baseline-критичным `full` рассмотреть
  `pm2 stop ruflo-daemon` — worker-jitter усиливает Pest-flake.
- gitleaks и lychee: на Windows берутся из `bin\*.exe`, на Linux/Mac CI — из
  `PATH`. Отсутствие бинаря → `[⚠] SKIPPED` + вердикт `RED-INCOMPLETE`.

## Не использовать когда

- Нужна одна конкретная проверка — запусти её npm/composer-скрипт напрямую
  (быстрее, чем весь свод).
- Pa11y и Semgrep SAST — это CI-tier, в свод намеренно не входят (см. дизайн-спек
  `docs/superpowers/specs/2026-05-16-regression-skill-design.md` §5).
````

- [ ] **Step 2: Verify the skill document**

Run: `npx markdownlint-cli2 ".claude/skills/regression/SKILL.md"`
Expected: `0 error(s)`.

Run: `npx cspell --no-progress --no-summary ".claude/skills/regression/SKILL.md"`
Expected: exit 0, no output. If cspell reports an unknown word that is a valid project transliteration, add it (lowercase) to `cspell-words.txt` under a dated comment header and re-run.

- [ ] **Step 3: Commit**

```bash
git add .claude/skills/regression/SKILL.md
# include cspell-words.txt in this commit only if Step 2 required adding words
git commit -m "feat(regression): SKILL.md — skill doc + invocation rules

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

(This commit touches a `.md` file, so `lefthook` runs gitleaks + markdownlint + cspell. All must pass.)

---

### Task 9: Functional verification + final report

Verify the whole skill end-to-end against the spec §10 functional checklist. No new code — this task runs the skill and confirms behaviour.

**Files:**

- No file changes (verification only). If a defect is found, fix it in `run.mjs`, re-run `node --test`, and commit the fix as `fix(regression): ...`.

- [ ] **Step 1: Full unit suite green**

Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: 36 tests pass, 0 fail.

- [ ] **Step 2: Happy path — `quick`**

Run: `node .claude/skills/regression/run.mjs quick`
Expected: header `─ /regression quick ─…`, 6 rows, a `Canonical:` line with 6 tokens, `VERDICT:` line. On a clean tree → `🟢 GREEN`, exit code 0. Verify the exit code: `echo $?` (bash) / `$LASTEXITCODE` (PowerShell) → `0`.

- [ ] **Step 3: Happy path — `full`**

Run: `node .claude/skills/regression/run.mjs full`
Expected: 12 rows, `Canonical:` line with 12 tokens, verdict. This run takes minutes (Pest `--parallel` + Vitest + Vite build). Cross-check the count tokens against the tools' own summaries — e.g. the `Pest …` token must match Pest's `Tests:` line. If Pest shows 1–3 intermittent errors matching quirk 72/73/77, re-run once before treating RED as real (see `SKILL.md` Caveats).

- [ ] **Step 4: RED path**

Introduce a deliberate lint error in a TypeScript file, e.g. append `const x:number='oops'` to a scratch line of an existing `app/resources/js/**/*.ts` file. Run: `node .claude/skills/regression/run.mjs quick`
Expected: `🔴 RED`, exit code 1, the verdict lists `vue-tsc` and/or `ESLint`, and the failed check's full output is printed after the verdict. Then revert the deliberate error and confirm `quick` returns to `🟢 GREEN`.

- [ ] **Step 5: RED-INCOMPLETE path**

Temporarily rename `bin/gitleaks.exe` → `bin/gitleaks.exe.bak`. Run: `node .claude/skills/regression/run.mjs full`
Expected: the gitleaks row shows `[⚠] gitleaks … SKIPPED`, the verdict is `🟠 RED-INCOMPLETE`, exit code 1. Then rename the binary back and confirm `full` no longer reports the skip.

- [ ] **Step 6: Final commit (only if Steps 1–5 required a fix)**

If any step revealed a defect that you fixed in `run.mjs`/`run.test.mjs`, commit it:

```bash
git add .claude/skills/regression/run.mjs .claude/skills/regression/run.test.mjs
git commit -m "fix(regression): <what the functional verification caught>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

If Steps 1–5 all passed with no fix needed, there is nothing to commit — the skill is complete.

- [ ] **Step 7: Report**

Report to the user: the skill is verified per spec §10 (unit suite green + functional checklist passed), with the actual `node --test` output and the `quick`/`full` verdict lines quoted. Note that `superpowers:writing-skills` discipline is satisfied (skill verified before being declared done).

---

## Self-Review

**1. Spec coverage** — every spec section maps to a task:

- §2 scope (two tiers, orchestrator, canonical line, invocation rules, caveats, `run.test.mjs`) → Tasks 1–8.
- §3 decisions Q1–Q4 + U1–U3 + P1 → encoded in `CHECKS` tiers (Task 6), `.mjs` orchestrator (Tasks 1–7), `main` default `full` (Task 7), `run.test.mjs` (Tasks 1–7).
- §4 components (`SKILL.md`, `run.mjs`, `run.test.mjs`) → Tasks 8, 1–7, 1–7.
- §5 check matrix (12 checks, exclusions) → `CHECKS` registry (Task 6).
- §6 execution model (run-all, sequential, quick-first, platform fork) → `main` + `runCheck` (Task 7), `resolveBinary` (Task 1).
- §7 output (table, canonical line, verdict, exit codes) → Tasks 5, 7.
- §8 error handling (missing binary → SKIPPED/RED-INCOMPLETE, Pest flake caveat, ruflo caveat) → `runCheck` (Task 7), `SKILL.md` Caveats (Task 8).
- §9 invocation/self-restraint → `SKILL.md` (Task 8); frontmatter has no `disable-model-invocation` (Task 8).
- §10 testing (`node:test` unit + subprocess + functional 1–3) → Tasks 1–7 unit, Task 7 subprocess, Task 9 functional.
- §12 normative compliance (no `CLAUDE.md` edit, no schema/Q-item) → enforced by design; `SKILL.md` states the no-`CLAUDE.md`-edit rule.

No gaps.

**2. Placeholder scan** — every code step contains complete, runnable code; every test step contains real assertions on concrete fixtures; every run step has an exact command and expected output. No "TBD" / "implement later" / "add error handling".

**3. Type consistency** — the result object shape `{ id, label, code, skipped, ms, token, stdout, stderr }` is produced by `runCheck` (Task 7) and consumed by `computeVerdict` (reads `label`/`code`/`skipped` — Task 4), `formatRow` (reads `label`/`code`/`ms`/`skipped` — Task 5), `buildCanonicalLine` (reads `token` — Task 5). `parse(combinedOutput, exitCode)` signature: defined in `CHECKS` (Task 6), called by `runCheck` (Task 7), matches each parser's signature (Tasks 2–3 — content parsers ignore the 2nd arg except `parseGitleaks`). `computeVerdict` returns `{ verdict, exitCode, failed, skipped }`, consumed by `verdictLine` and `main` (Tasks 5, 7) — consistent. Function names are identical across all tasks.

---

**Execution note:** `run.mjs`/`run.test.mjs` commits are gitleaks-only under `lefthook`; the `SKILL.md` commit additionally runs markdownlint + cspell. Plan and spec are consistent; the one deliberate refinement (failed-check output printed after the verdict rather than literally "above the table") is annotated inline in Task 7.
