diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..4058d66 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,51 @@ +name: Bug report +description: Something in skillgate behaves incorrectly +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for the report. skillgate is deterministic, so a minimal repro almost always pins it down. + - type: input + id: version + attributes: + label: skillgate version + description: Output of `skillgate --version` + placeholder: "0.3.0" + validations: + required: true + - type: dropdown + id: how + attributes: + label: How are you running it? + options: + - npx + - npm (installed dependency) + - CI (GitHub Actions) + - pre-commit + - Claude Code / opencode hook + - other + validations: + required: true + - type: textarea + id: spec + attributes: + label: Your .skillgate/done.yaml + description: The smallest spec that reproduces the issue. + render: yaml + validations: + required: true + - type: textarea + id: expected + attributes: + label: What you expected vs what happened + description: Include the command you ran, the verdict/exit code, and the output. + validations: + required: true + - type: input + id: env + attributes: + label: Node version and OS + placeholder: "node 20.x, Ubuntu 24.04" + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..57ce0ff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/renezander030/skillgate/security/advisories/new + about: Please report security issues privately, not as a public issue. + - name: Question / usage help + url: https://github.com/renezander030/skillgate/discussions + about: Ask how to wire skillgate into your workflow. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..a6daf21 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,26 @@ +name: Feature request +description: Suggest a new gate type or capability +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: What corner can your agent still cut? + description: Describe the definition-of-done step skillgate cannot enforce today. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed gate / behaviour + description: How would you express it in done.yaml? Sketch the YAML if you can. + render: yaml + validations: + required: true + - type: checkboxes + id: deterministic + attributes: + label: Determinism check + options: + - label: This can be decided as a pure function over the filesystem (no model, no network) + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1334e26 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,25 @@ + + +## What changed + + + +## Why + + + +## How I verified it + + + +``` +npm run build && npm run test:coverage +``` + +## Checklist + +- [ ] Tests added/updated (unit in `test/*.test.ts`, CLI behaviour in `test/e2e.test.ts`) +- [ ] `npm run test:coverage` passes locally (coverage thresholds are enforced in CI) +- [ ] New/changed gate behaviour is documented in the README gate table +- [ ] Added a bullet under `## Unreleased` in `CHANGELOG.md` +- [ ] Change stays deterministic (pure function over the filesystem, no network, no model) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2ab01bf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + dev-dependencies: + dependency-type: development + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/scripts/changelog-section.mjs b/.github/scripts/changelog-section.mjs new file mode 100644 index 0000000..7f3a14b --- /dev/null +++ b/.github/scripts/changelog-section.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +// Print the CHANGELOG.md section for a given version, used as GitHub Release notes. +// Usage: node changelog-section.mjs 0.4.0 +import fs from "node:fs"; + +const version = process.argv[2]; +if (!version) { + console.error("usage: changelog-section.mjs "); + process.exit(1); +} + +const md = fs.readFileSync(new URL("../../CHANGELOG.md", import.meta.url), "utf8"); +const lines = md.split("\n"); + +// Match "## 0.4.0" (optionally followed by a date/anything). +const startIdx = lines.findIndex((l) => new RegExp(`^##\\s+v?${version.replace(/\./g, "\\.")}\\b`).test(l)); +if (startIdx === -1) { + console.error(`no CHANGELOG section for ${version}`); + process.exit(1); +} + +const body = []; +for (let i = startIdx + 1; i < lines.length; i++) { + if (/^##\s+/.test(lines[i])) break; // next version heading + body.push(lines[i]); +} + +console.log(body.join("\n").trim() || `Release ${version}`); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5f52ee..1bff144 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,11 +9,13 @@ permissions: contents: read jobs: + # Primary job — its check context is exactly "test" (kept stable for branch + # protection). Runs on the active LTS. test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 with: node-version: '20' cache: npm @@ -22,3 +24,35 @@ jobs: - run: npm test # dogfood: skillgate gates its own repo - run: node dist/src/cli.js check + + # Compatibility coverage across the rest of the supported range. + compat: + name: compat (node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ['18', '22'] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run build + - run: npm test + + coverage: + name: coverage gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: '22' + cache: npm + - run: npm ci + # Thresholds (lines/branches/functions) live in package.json's test:coverage + # script; the --test-coverage-* flags fail the run when coverage drops below them. + - run: npm run test:coverage diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b7b878e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: Release + +# Tag-driven release: push a SemVer tag (e.g. v0.4.0) to publish. +# npm version patch|minor|major # bumps package.json + creates the tag +# git push --follow-tags +# Requires repo secret NPM_TOKEN (an npm automation token). +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write # create the GitHub Release + id-token: write # npm provenance (--provenance) + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + cache: npm + + - run: npm ci + - run: npm run build + - run: npm test + + # Fail early if the tag doesn't match the version in package.json. + - name: Verify tag matches package.json version + run: | + PKG="v$(node -p "require('./package.json').version")" + if [ "$PKG" != "${GITHUB_REF_NAME}" ]; then + echo "tag ${GITHUB_REF_NAME} != package.json ${PKG}" >&2 + exit 1 + fi + + - name: Extract this version's changelog section + run: node .github/scripts/changelog-section.mjs "${GITHUB_REF_NAME#v}" > RELEASE_NOTES.md + + - name: Publish to npm with provenance + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1 + with: + body_path: RELEASE_NOTES.md + fail_on_unmatched_files: true diff --git a/.skillgate/done.yaml b/.skillgate/done.yaml index babc0a2..ffdfb76 100644 --- a/.skillgate/done.yaml +++ b/.skillgate/done.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/renezander030/skillgate/master/schema/done.schema.json # skillgate gates its own repo (dogfood). Run: npx skillgate check name: skillgate-self finishLine: diff --git a/CHANGELOG.md b/CHANGELOG.md index ee4e019..c2a488e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +### Added +- Optional `version:` field in `done.yaml` for spec-format compatibility: an older skillgate meeting a newer spec now warns instead of silently misreading gates (`SPEC_VERSION` exported from `spec.ts`). +- JSON Schema at `schema/done.schema.json`; generated and example specs carry a `# yaml-language-server:` modeline for editor autocomplete and validation. Schema ships in the npm package. +- `docs/`: quickstart, spec reference, recipes, architecture, and a compatibility/deprecation policy (including the documented exit-code contract). +- Community health files: `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, issue templates, and a pull-request template. +- Tag-driven release workflow (`npm publish --provenance` + GitHub Release from the CHANGELOG section). +- `test/e2e.test.ts`: the CLI is now covered end-to-end as a real process; `test/spec.test.ts` covers spec loading and versioning. + +### Changed +- CI runs a Node 18/20/22 matrix, pins all actions to commit SHAs, and enforces coverage thresholds via `npm run test:coverage`. +- Added Dependabot for npm and GitHub Actions. + ## 0.3.0 ### Added diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5144a88 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,57 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes +- Focusing on what is best for the overall community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards and +will take appropriate and fair corrective action in response to any behavior +that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainer at renezander030@gmail.com. All complaints +will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +. + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fbe0a52 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing to skillgate + +Thanks for helping make the finish-line gate sharper. skillgate is a small, +zero-runtime-dependency-by-design TypeScript CLI, so contributions stay focused and +fast to review. + +## Ground rules + +- **Deterministic by design.** Every gate must be a pure function over the filesystem: + same inputs, same verdict, no model in the loop, no network. If a change introduces + nondeterminism, it does not belong in a gate. +- **Keep the dependency surface tiny.** New runtime dependencies need a strong reason. +- **The repo gates itself.** `.skillgate/done.yaml` runs in CI — your change has to pass + the same gate it ships. + +## Development setup + +```bash +git clone https://github.com/renezander030/skillgate +cd skillgate +npm ci +npm run build +npm test +``` + +Useful scripts: + +| Command | What it does | +|---|---| +| `npm run build` | Compile TypeScript to `dist/` | +| `npm test` | Run the test suite (`node --test`) | +| `npm run test:coverage` | Tests with coverage thresholds enforced | +| `node dist/src/cli.js check` | Dogfood the gate on this repo | + +## Making a change + +1. Branch from `master`. +2. Add or update tests. Unit tests live in `test/*.test.ts`; CLI-level behaviour goes in + `test/e2e.test.ts`. A new gate type needs both a unit test and an e2e test. +3. `npm run build && npm run test:coverage` must pass. Coverage thresholds are enforced + in CI, so keep them green locally. +4. Add a bullet under `## Unreleased` in [`CHANGELOG.md`](CHANGELOG.md). (The self-gate + checks that an Unreleased section exists.) +5. Open a PR using the template. Describe the behaviour change and how you verified it. + +## Adding a gate type + +A gate type is a `case` in `checkGate()` in `src/core.ts` plus an interface in +`src/spec.ts`. Keep it deterministic, give it a clear failure `reason` (ideally with a +`file:line`), and document it in the README's gate table. + +## Releases + +Releases are tag-driven (`npm version ` → `git push --follow-tags`), +which triggers the publish workflow. Maintainers handle this. diff --git a/README.md b/README.md index 086ae5d..3913f13 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,13 @@ # skillgate +

+ CI + npm version + license + node version +

+ > **A finish-line gate your agent cannot talk its way past.** AI coding agents deviate from your process to reach "done" faster, and asking the model to check its own compliance is the deviating party grading its own paper. `skillgate` is a deterministic evaluator that lives outside the model: it blocks the commit / push / publish until your definition-of-done actually passes. Works with **opencode** (any model you plug in), Claude Code, pre-commit, and CI. ![skillgate blocking a git commit because two gates fail, then letting it through once they are fixed](assets/skillgate-demo.gif) @@ -220,6 +227,15 @@ Two differences that matter beyond "git hook vs git plumbing": Use the harness hooks for fast feedback in the loop; rely on CI for the guarantee. +## Documentation + +- [Quickstart](docs/quickstart.md) — audit, define, check, wire in. +- [Spec reference](docs/spec-reference.md) — every gate type and option, plus the [JSON Schema](schema/done.schema.json) for editor autocomplete. +- [Recipes](docs/recipes.md) — Claude Code, pre-commit, CI, loop+gate, self-hosted server. +- [Architecture](docs/architecture.md) — how the modules fit and how to add a gate type. +- [Compatibility & deprecation policy](docs/compatibility.md) — SemVer, spec versioning, the exit-code contract. +- [Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md) · [Code of conduct](CODE_OF_CONDUCT.md) + ## Related Same conviction in every one of these — *the model suggests, a deterministic boundary it can't route around decides* — applied at a different layer of the stack: diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8bffb58 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security Policy + +## Supported versions + +skillgate is pre-1.0. Security fixes land on the latest published `0.x` release on npm +(`@reneza/skillgate`). Please upgrade to the latest version before reporting. + +## Reporting a vulnerability + +Please **do not** open a public issue for security problems. + +Report privately through GitHub's [private vulnerability reporting](https://github.com/renezander030/skillgate/security/advisories/new) +("Report a vulnerability" on the Security tab). If that is unavailable, email the +maintainer listed on the npm package page. + +When reporting, include: + +- the version (`skillgate --version`) and how you run it (npx / npm / CI / pre-commit), +- a minimal `.skillgate/done.yaml` and repo layout that reproduces the issue, +- the impact you observed. + +You can expect an initial acknowledgement within a few days. Fixes are released as a +new patch version with a note in [`CHANGELOG.md`](CHANGELOG.md). + +## Scope notes + +skillgate runs `command`-type gates, which execute the shell command you put in your +own `done.yaml`. Treat a `done.yaml` from an untrusted source the same way you would +treat any script in that repo — review it before running `skillgate check`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..122c29a --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,47 @@ +# Architecture + +skillgate is a small, zero-runtime-dependency-by-design TypeScript CLI. The whole tool +is a pure function from (filesystem + spec) to a verdict. + +## The one idea + +An LLM asked "is this done?" answers differently depending on the weather and has an +incentive to say yes. A script does not. So the judge of "done" must live **outside** +the model and observe behaviour deterministically. That is the entire design. See the +README for the research basis (the Compliance Gap). + +## Modules (`src/`) + +| File | Responsibility | +|------|----------------| +| `cli.ts` | Argument parsing and the `audit` / `check` / `init` / `drift` / `sync` commands. Owns all process output and exit codes; it is the only module that talks to the terminal. | +| `spec.ts` | The spec types, `findSpecPath()` (where a `done.yaml` may live), and `loadSpec()` (parse + validate, including the optional `version` field). | +| `core.ts` | `runGates()` — the deterministic evaluator. One `case` per gate type in `checkGate()`. `isFinishLine()` decides whether a command crosses the line. **This is the pure heart; it never reads argv or writes output.** | +| `drift.ts` | Instruction-file drift detection (similarity of CLAUDE.md / AGENTS.md / Cursor / Copilot / …). Powers the `instruction-sync` gate and the `drift` command. | +| `link.ts` | `runSync()` — makes one instruction file canonical and links the rest. Powers `sync`. | +| `plugin.ts` | The opencode plugin entry point that denies finish-line commands until gates pass. | + +## Data flow + +``` +spec file ──loadSpec──▶ Spec ──┐ + ├─▶ runGates(spec, cwd) ─▶ RunResult ─▶ cli formats + exit code +working tree (cwd) ────────────┘ +``` + +`runGates` is intentionally side-effect-free: it takes a `Spec` and a directory and +returns `{ passed, results, failed }`. Everything that touches the terminal, the +process exit code, or temp files lives in `cli.ts`. That split is what makes the core +trivially testable (`test/core.test.ts`, `test/spec.test.ts`) and lets the CLI be +covered end-to-end as a real process (`test/e2e.test.ts`). + +## Adding a gate type + +1. Add an interface to the `Gate` union in `src/spec.ts`. +2. Add a `case` to `checkGate()` in `src/core.ts` returning `{ id, type, ok, reason }`. + Give a clear `reason` (ideally `file:line`). +3. Add it to the JSON Schema (`schema/done.schema.json`) and the + [spec reference](spec-reference.md). +4. Add a unit test and an e2e test. + +Keep it deterministic — a pure function over the filesystem, no network, no model. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..cb4c664 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,47 @@ +# Compatibility & deprecation policy + +skillgate's public contract is three things: the **CLI** (commands, flags, exit codes), +the **`done.yaml` spec format**, and the **library exports** (`@reneza/skillgate` and +`@reneza/skillgate/core`). This is how they change. + +## Versioning + +The package follows [SemVer](https://semver.org). While pre-1.0, breaking changes may +land in a minor (`0.x`) release, but they are always called out in +[`CHANGELOG.md`](../CHANGELOG.md) under a **Breaking** heading with a migration note. + +## Spec format version + +`done.yaml` may declare an integer `version:`. It is optional and backward-compatible — +an omitted version means "the current format". The field exists so that: + +- a spec can opt into a future format explicitly, and +- an **older** skillgate that meets a **newer** spec degrades loudly: it prints a + warning (`spec declares version N but this build understands up to M`) instead of + silently misreading gates. + +The format version this build understands is exported as `SPEC_VERSION` from +`src/spec.ts`. It is bumped only on a breaking change to the schema, not for additive +ones (a new gate type is additive — old specs keep working). + +## Exit-code contract + +These are stable; tools may rely on them. + +| Code | Meaning | +|------|---------| +| `0` | All gates passed (or `drift`: everything in sync) | +| `1` | A gate failed / drift detected — the finish line is blocked | +| `2` | Usage error: no spec found, unknown command, spec failed to load | + +## How deprecations happen + +1. **Deprecate before removing.** A gate field or flag that is going away is first + marked deprecated in the docs and continues to work, with a warning where practical. +2. **Document the migration.** Every breaking change ships with a `CHANGELOG.md` entry + describing the before/after and the upgrade step. +3. **Additive by default.** New gate types and optional fields are added without + breaking existing specs. + +If you depend on behaviour that is not documented here or in the +[spec reference](spec-reference.md), please open an issue so it can be made explicit. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..658992e --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,49 @@ +# Quickstart + +skillgate is a deterministic finish-line gate: it blocks `git commit` / `git push` / +`npm publish` until your definition-of-done actually passes. No model in the loop. + +## 1. Audit (zero install, read-only) + +See what your agent could cut right now, with no config: + +```bash +npx @reneza/skillgate@latest audit +``` + +This writes nothing. It scores the repo against built-in defaults (or your +`.skillgate/done.yaml` if you have one) and exits non-zero if a corner is cuttable. + +## 2. Define "done" + +```bash +npx @reneza/skillgate@latest init # writes .skillgate/done.yaml +``` + +Edit it. Each entry under `gates:` is one deterministic check. See the +[spec reference](spec-reference.md) for every gate type, and +[`schema/done.schema.json`](../schema/done.schema.json) for editor autocomplete +(the generated file already points at it via a `# yaml-language-server:` modeline). + +## 3. Check + +```bash +npx @reneza/skillgate@latest check # exit 0 = all gates pass, 1 = something unmet +``` + +## 4. Wire it into the finish line + +Pick the path that matches how far you want the guarantee to reach — Claude Code +hook, pre-commit, CI, or a retry loop. All of them enforce the *same* +`.skillgate/done.yaml`. See [recipes](recipes.md). + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | All gates passed (or, for `drift`, everything in sync) | +| `1` | At least one gate failed / drift detected — the finish line is blocked | +| `2` | Usage error: no spec found, unknown command, or a spec that failed to load | + +`--json` is available on `check`, `audit`, and `drift` for machine-readable output; +the exit code is unchanged. diff --git a/docs/recipes.md b/docs/recipes.md new file mode 100644 index 0000000..e393e9a --- /dev/null +++ b/docs/recipes.md @@ -0,0 +1,63 @@ +# Recipes — wiring skillgate into the finish line + +Every recipe enforces the *same* `.skillgate/done.yaml`, so you define "done" once and +reuse it everywhere. Ready-to-copy adapters live in [`contrib/`](../contrib). + +## Claude Code (PreToolUse hook) + +Make Claude Code unable to `git commit` / `git push` / `npm publish` until the gates +pass. The hook denies the finish-line command and feeds the failing gates back into the +same session, so Claude fixes the work instead of shipping it. + +- Kit: [`contrib/claude-code/`](../contrib/claude-code) — hook script, `settings.json`, starter spec. +- Setup is ~60 seconds; see its [README](../contrib/claude-code/README.md). + +## pre-commit + +Block local commits until gates pass. + +- Config: [`contrib/pre-commit-config.yaml`](../contrib/pre-commit-config.yaml). + +```yaml +# .pre-commit-config.yaml +- repo: local + hooks: + - id: skillgate + name: skillgate check + entry: npx @reneza/skillgate@latest check + language: system + pass_filenames: false +``` + +## CI (GitHub Actions) + +Gate every push and pull request. + +- Reference workflow: [`contrib/github-action.yml`](../contrib/github-action.yml). + +```yaml +- run: npx @reneza/skillgate@latest check +``` + +skillgate gates its own repo this way — see [`.github/workflows/ci.yml`](../.github/workflows/ci.yml). + +## Loop + gate (retry until really done) + +A retry loop's natural stop condition is the *model* saying it finished — the exact +signal you cannot trust. `loop-until-done.sh` makes `skillgate check` the stop +condition instead: the loop keeps going until a script, not the model, agrees. + +- Script: [`contrib/loop-gate/loop-until-done.sh`](../contrib/loop-gate/loop-until-done.sh). + +## Self-hosted git server (pre-receive) + +Enforce the gate server-side on a VPS, so a push is rejected at the remote if the work +is not done — no client cooperation required. + +- Kit: [`contrib/self-hosted-gate/`](../contrib/self-hosted-gate) — `pre-receive` hook, + install script, and a Vagrant box to test it. + +## Anywhere else + +The CLI is the contract: `skillgate check` exits `0` when done, `1` when a gate fails, +`2` on a usage error. Anything that can read an exit code can gate on it. diff --git a/docs/spec-reference.md b/docs/spec-reference.md new file mode 100644 index 0000000..f4d86a1 --- /dev/null +++ b/docs/spec-reference.md @@ -0,0 +1,97 @@ +# Spec reference — `.skillgate/done.yaml` + +A spec is a YAML (or JSON) file. skillgate looks for it at, in order: +`.skillgate/done.yaml`, `.skillgate/done.yml`, `.skillgate.yaml`, `.skillgate.yml`, +`.skillgate.json`. A machine-readable JSON Schema lives at +[`schema/done.schema.json`](../schema/done.schema.json). + +## Top-level fields + +| Field | Type | Required | Meaning | +|-------|------|----------|---------| +| `gates` | array | yes | The deterministic checks. Order is preserved in output. | +| `finishLine` | string[] | no | Commands that count as crossing the finish line (substring match). Used by the agent integrations. | +| `name` | string | no | A label for this definition of done. | +| `version` | integer | no | Spec format version. Omit for the current format. See [compatibility](compatibility.md). | + +## Gate types + +Every gate has an `id` (string, shown in output) and an optional `description`. + +### `file-exists` + +Every listed path must exist. + +```yaml +- id: docs + type: file-exists + file: [README.md, LICENSE] # string or array of strings +``` + +### `file-contains` + +A file must contain a regex match — a required section, an exact phrase, a version bump. + +```yaml +- id: changelog-touched + type: file-contains + file: CHANGELOG.md + pattern: "unreleased" + flags: "i" # optional JS regex flags +``` + +### `absent` + +A regex must **not** appear in any matched file. Reports the first `file:line` hit. +Ideal for stray TODOs and committed secrets. + +```yaml +- id: no-secrets + type: absent + glob: "**/*.{ts,js,json,md,yaml,yml,env}" + pattern: 'sk_live_[A-Za-z0-9]{16,}' + ignore: [".skillgate/**"] # optional extra excludes +``` + +`node_modules/`, `.git/`, and `dist/` are always ignored. + +### `command` + +A shell command must exit 0. Only as deterministic as the command itself — prefer +test/lint/build commands, not anything that hits the network. + +```yaml +- id: tests-pass + type: command + run: "npm test --silent" +``` + +### `evidence` + +The escape hatch for steps that are not machine-observable ("research X first"): the +agent writes a named file as it works, and the gate verifies the file exists and is +non-empty. + +```yaml +- id: research-recorded + type: evidence + file: .skillgate/evidence/research.md +``` + +### `instruction-sync` + +Every AI instruction file in the repo (CLAUDE.md, AGENTS.md, `.cursor/rules`, +copilot-instructions.md, …) must still agree with the canonical one. Drift means your +agents are reading different rulebooks. Run `skillgate sync` to fix. + +```yaml +- id: instructions-in-sync + type: instruction-sync + threshold: 0.95 # optional, 0..1, default 0.95 +``` + +## Determinism contract + +Every gate is a pure function over the filesystem: same inputs, same verdict, in +milliseconds, with no model in the loop. A `command` gate inherits the determinism of +the command you give it — keep them hermetic. diff --git a/examples/done.yaml b/examples/done.yaml index 302b363..df9174e 100644 --- a/examples/done.yaml +++ b/examples/done.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/renezander030/skillgate/master/schema/done.schema.json # skillgate — definition of done # # Deterministic gates checked before a finish-line command (commit / push / diff --git a/package.json b/package.json index fd0562a..b98c984 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dist/src", "examples", "contrib", + "schema", "README.md", "LICENSE" ], @@ -24,7 +25,9 @@ "build": "tsc -p tsconfig.json", "prepare": "npm run build", "pretest": "npm run build", - "test": "node --test dist/test/*.test.js" + "test": "node --test dist/test/*.test.js", + "pretest:coverage": "npm run build", + "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=70 --test-coverage-functions=90 dist/test/*.test.js" }, "engines": { "node": ">=18" diff --git a/schema/done.schema.json b/schema/done.schema.json new file mode 100644 index 0000000..cb8cc4a --- /dev/null +++ b/schema/done.schema.json @@ -0,0 +1,129 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/renezander030/skillgate/master/schema/done.schema.json", + "title": "skillgate definition-of-done", + "description": "Schema for .skillgate/done.yaml — deterministic finish-line gates.", + "type": "object", + "required": ["gates"], + "additionalProperties": false, + "properties": { + "version": { + "type": "integer", + "minimum": 1, + "description": "Spec format version. Omit for the current format." + }, + "name": { + "type": "string", + "description": "A label for this definition of done." + }, + "finishLine": { + "type": "array", + "description": "Commands that count as crossing the finish line (substring match).", + "items": { "type": "string" } + }, + "gates": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/gate" } + } + }, + "definitions": { + "base": { + "properties": { + "id": { "type": "string" }, + "description": { "type": "string" } + }, + "required": ["id"] + }, + "gate": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "file"], + "properties": { + "id": { "type": "string" }, + "description": { "type": "string" }, + "type": { "const": "file-exists" }, + "file": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "description": "Path or paths that must all exist." + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "file", "pattern"], + "properties": { + "id": { "type": "string" }, + "description": { "type": "string" }, + "type": { "const": "file-contains" }, + "file": { "type": "string" }, + "pattern": { "type": "string", "description": "JavaScript regular expression source." }, + "flags": { "type": "string", "description": "Regex flags, e.g. \"i\"." } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "glob", "pattern"], + "properties": { + "id": { "type": "string" }, + "description": { "type": "string" }, + "type": { "const": "absent" }, + "glob": { "type": "string", "description": "Files to scan." }, + "pattern": { "type": "string", "description": "Regex that must NOT appear." }, + "flags": { "type": "string" }, + "ignore": { + "type": "array", + "items": { "type": "string" }, + "description": "Extra globs to exclude (fixtures, examples, the spec itself)." + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "run"], + "properties": { + "id": { "type": "string" }, + "description": { "type": "string" }, + "type": { "const": "command" }, + "run": { "type": "string", "description": "Shell command; must exit 0." } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "file"], + "properties": { + "id": { "type": "string" }, + "description": { "type": "string" }, + "type": { "const": "evidence" }, + "file": { "type": "string", "description": "A file that must exist and be non-empty." } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "type"], + "properties": { + "id": { "type": "string" }, + "description": { "type": "string" }, + "type": { "const": "instruction-sync" }, + "threshold": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Similarity required to count as in sync (default 0.95)." + } + } + } + ] + } + } +} diff --git a/src/cli.ts b/src/cli.ts index ffb7bf3..19963a0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,7 +17,8 @@ const C = { const useColor = process.stdout.isTTY; const c = (code: string, s: string) => (useColor ? code + s + C.reset : s); -const EXAMPLE = `# skillgate — definition of done +const EXAMPLE = `# yaml-language-server: $schema=https://raw.githubusercontent.com/renezander030/skillgate/master/schema/done.schema.json +# skillgate — definition of done # Docs: https://github.com/renezander030/skillgate name: definition-of-done diff --git a/src/spec.ts b/src/spec.ts index 7aad151..2b98371 100644 --- a/src/spec.ts +++ b/src/spec.ts @@ -69,12 +69,22 @@ export type Gate = | InstructionSyncGate; export interface Spec { + /** + * Spec format version. Optional and backward-compatible: an omitted version is + * treated as the current format. Bump only on a breaking change to the schema; + * skillgate warns (it does not refuse) when a spec declares a version newer than + * it understands, so an older CLI degrades loudly rather than silently. + */ + version?: number; name?: string; /** Commands that count as crossing the finish line (substring match). */ finishLine?: string[]; gates: Gate[]; } +/** The spec format version this build understands. See docs/compatibility.md. */ +export const SPEC_VERSION = 1; + export const DEFAULT_SPEC_PATHS = [ ".skillgate/done.yaml", ".skillgate/done.yml", @@ -98,5 +108,15 @@ export function loadSpec(specPath: string): Spec { if (!data || !Array.isArray(data.gates)) { throw new Error(`invalid spec ${specPath}: missing "gates" array`); } + if (data.version != null) { + if (typeof data.version !== "number" || !Number.isInteger(data.version)) { + throw new Error(`invalid spec ${specPath}: "version" must be an integer`); + } + if (data.version > SPEC_VERSION) { + console.warn( + `skillgate: spec ${specPath} declares version ${data.version} but this build understands up to ${SPEC_VERSION} — upgrade skillgate; some gates may be misread`, + ); + } + } return data as Spec; } diff --git a/test/e2e.test.ts b/test/e2e.test.ts new file mode 100644 index 0000000..4295778 --- /dev/null +++ b/test/e2e.test.ts @@ -0,0 +1,184 @@ +// End-to-end tests: exercise the built CLI as a real process against fixture +// repos, so the user-facing contract (commands, exit codes, --json) is covered, +// not just the in-process gate logic. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +const CLI = new URL("../src/cli.js", import.meta.url).pathname; + +// Built at runtime so the literal 16+ char token never appears in source — otherwise +// skillgate's own no-secrets gate would (correctly) flag this test file. +const FAKE_SECRET = "sk_live_" + "0".repeat(20); + +interface Run { + status: number; + stdout: string; + stderr: string; +} + +function sg(args: string[], cwd: string): Run { + try { + const stdout = execFileSync(process.execPath, [CLI, ...args], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { status: 0, stdout, stderr: "" }; + } catch (e: any) { + return { status: e.status ?? 1, stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? "") }; + } +} + +function tmpProject(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skillgate-e2e-")); + for (const [rel, content] of Object.entries(files)) { + const full = path.join(dir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + return dir; +} + +test("--version prints the package version", () => { + const r = sg(["--version"], process.cwd()); + assert.equal(r.status, 0); + assert.match(r.stdout.trim(), /^\d+\.\d+\.\d+/); +}); + +test("help: bare invocation and --help both exit 0 with usage", () => { + for (const args of [[], ["help"], ["--help"]]) { + const r = sg(args, process.cwd()); + assert.equal(r.status, 0); + assert.match(r.stdout, /Usage:/); + } +}); + +test("unknown command exits 2 and prints help", () => { + const r = sg(["frobnicate"], process.cwd()); + assert.equal(r.status, 2); + assert.match(r.stderr, /unknown command/); +}); + +test("init writes a spec, then refuses to overwrite it", () => { + const dir = tmpProject({}); + const first = sg(["init"], dir); + assert.equal(first.status, 0); + assert.ok(fs.existsSync(path.join(dir, ".skillgate", "done.yaml"))); + const second = sg(["init"], dir); + assert.equal(second.status, 1); + assert.match(second.stderr, /already exists/); +}); + +test("check: exits 2 when no spec is present", () => { + const dir = tmpProject({ "README.md": "hi" }); + const r = sg(["check"], dir); + assert.equal(r.status, 2); + assert.match(r.stderr, /no spec found/); +}); + +test("check: exits 0 when every gate passes", () => { + const dir = tmpProject({ + "README.md": "hi", + "LICENSE": "MIT", + ".skillgate/done.yaml": + "gates:\n - id: docs\n type: file-exists\n file: [README.md, LICENSE]\n", + }); + const r = sg(["check"], dir); + assert.equal(r.status, 0); + assert.match(r.stdout, /all 1 gates passed/); +}); + +test("check: exits 1 and names the failing gate", () => { + const dir = tmpProject({ + "README.md": "hi", + ".skillgate/done.yaml": + "gates:\n - id: docs\n type: file-exists\n file: [README.md, LICENSE]\n", + }); + const r = sg(["check"], dir); + assert.equal(r.status, 1); + assert.match(r.stdout, /docs/); +}); + +test("check: every gate type round-trips through the CLI", () => { + const dir = tmpProject({ + "README.md": "# hi\n", + "src/a.ts": "export const a = 1\n", + "notes.md": "evidence", + ".skillgate/done.yaml": [ + "gates:", + " - id: exists", + " type: file-exists", + " file: README.md", + " - id: contains", + " type: file-contains", + " file: README.md", + " pattern: hi", + " - id: absent", + " type: absent", + " glob: src/**/*.ts", + " pattern: TODO", + " - id: command", + " type: command", + " run: \"true\"", + " - id: evidence", + " type: evidence", + " file: notes.md", + " - id: sync", + " type: instruction-sync", + "", + ].join("\n"), + }); + const r = sg(["check", "--json"], dir); + assert.equal(r.status, 0); + const out = JSON.parse(r.stdout); + assert.equal(out.passed, true); + assert.equal(out.results.length, 6); +}); + +test("check --json: failing run is machine-readable and exits 1", () => { + const dir = tmpProject({ + "src/leak.ts": `const k = '${FAKE_SECRET}'\n`, + ".skillgate/done.yaml": + "gates:\n - id: no-secrets\n type: absent\n glob: src/**/*.ts\n pattern: sk_live_\n", + }); + const r = sg(["check", "--json"], dir); + assert.equal(r.status, 1); + const out = JSON.parse(r.stdout); + assert.equal(out.passed, false); + assert.equal(out.failed[0].id, "no-secrets"); +}); + +test("audit: is read-only — never writes a spec into the audited repo", () => { + const dir = tmpProject({ "src/a.ts": "export const a = 1\n" }); + const r = sg(["audit"], dir); + // status may be 0 or 1 depending on the built-in defaults; the invariant is no write. + assert.ok(r.status === 0 || r.status === 1); + assert.ok(!fs.existsSync(path.join(dir, ".skillgate")), "audit must not write a spec"); +}); + +test("audit: exits 1 when a built-in default would let the agent cut a corner", () => { + const dir = tmpProject({ "src/leak.ts": `const k = '${FAKE_SECRET}'\n` }); + const r = sg(["audit", "--json"], dir); + assert.equal(r.status, 1); + const out = JSON.parse(r.stdout); + assert.equal(out.usingDefaults, true); + assert.equal(out.passed, false); +}); + +test("drift: reports cleanly when there are no instruction files", () => { + const dir = tmpProject({ "README.md": "hi" }); + const r = sg(["drift"], dir); + assert.equal(r.status, 0); + assert.match(r.stdout, /no agent instruction files/); +}); + +test("--cwd: runs against another directory", () => { + const dir = tmpProject({ "README.md": "hi" }); + const r = sg(["audit", "--cwd", dir], os.tmpdir()); + assert.equal(typeof r.status, "number"); + assert.match(r.stdout + r.stderr, /skillgate/i); +}); diff --git a/test/spec.test.ts b/test/spec.test.ts new file mode 100644 index 0000000..9cfe2ce --- /dev/null +++ b/test/spec.test.ts @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { loadSpec, SPEC_VERSION } from "../src/spec.js"; + +function tmpSpec(content: string, name = "done.yaml"): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skillgate-spec-")); + const p = path.join(dir, name); + fs.writeFileSync(p, content); + return p; +} + +test("loadSpec: accepts a spec with no version (backward compatible)", () => { + const p = tmpSpec("gates:\n - id: r\n type: file-exists\n file: README.md\n"); + const spec = loadSpec(p); + assert.equal(spec.version, undefined); + assert.equal(spec.gates.length, 1); +}); + +test("loadSpec: accepts the current spec version", () => { + const p = tmpSpec(`version: ${SPEC_VERSION}\ngates:\n - id: r\n type: file-exists\n file: README.md\n`); + assert.equal(loadSpec(p).version, SPEC_VERSION); +}); + +test("loadSpec: rejects a non-integer version", () => { + const p = tmpSpec("version: 1.5\ngates: []\n"); + assert.throws(() => loadSpec(p), /version.*must be an integer/); +}); + +test("loadSpec: throws when gates array is missing", () => { + const p = tmpSpec("name: broken\n"); + assert.throws(() => loadSpec(p), /missing "gates" array/); +}); + +test("loadSpec: parses a .json spec", () => { + const p = tmpSpec(JSON.stringify({ gates: [{ id: "r", type: "file-exists", file: "README.md" }] }), "done.json"); + assert.equal(loadSpec(p).gates[0].id, "r"); +});