Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .githooks/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/bin/sh
# Enforce Conventional Commits (CONTRIBUTING.md#commit-conventions).
# Installed by `npm run hooks:install` via core.hooksPath=.githooks
set -e

root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0

if ! command -v node >/dev/null 2>&1; then
echo "commit-msg: node not found — skipping Conventional Commits check." >&2
exit 0
fi

exec node "$root/tools/git-hooks/validate-commit-msg.mjs" "$1"
13 changes: 13 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/bin/sh
# Repository pre-commit checks (secrets, conflict markers, framework gates).
# Installed by `npm run hooks:install` via core.hooksPath=.githooks
set -e

root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0

if ! command -v node >/dev/null 2>&1; then
echo "pre-commit: node not found — skipping repository checks." >&2
exit 0
fi

exec node "$root/tools/git-hooks/pre-commit.mjs"
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Each agent owns a specific domain, enforcing separation of concerns and quality.
├── AGENTS.md # Project configuration
├── opencode.json # OpenCode config (permissions, plugins)
├── .pi/ # Pi settings and extensions
├── .githooks/ # Tracked git hooks (commit-msg, pre-commit)
├── .agents/ # Canonical content (harness-agnostic)
│ ├── agents/ # 52 agent definitions
│ ├── skills/ # 77 skill workflows
Expand Down Expand Up @@ -65,6 +66,7 @@ Each agent owns a specific domain, enforcing separation of concerns and quality.
├── design/ # Game design documents
├── tools/ # Build and pipeline tools
│ ├── migrate-to-agents.mjs # Migration script (.opencode/ → .agents/)
│ ├── git-hooks/ # Hook validators (commit-msg, pre-commit) + installer
│ └── ...
├── prototypes/ # Throwaway prototypes
└── production/ # Sprint plans, milestones, session logs
Expand Down Expand Up @@ -247,7 +249,12 @@ Before merging to `development`, the CI must pass:
- All skill files have valid cross-references to existing agents
- All command files reference valid skill directories
- **Plugin tests** (`npm run test:plugins`):
- 18 test suites, 158 tests covering all hooks
- 19 test suites, 177 tests covering all hooks
- **Git hooks** (`.githooks/`, installed by `npm run hooks:install`):
- `.githooks/commit-msg` enforces Conventional Commits (`npm run test:hooks`)
- `.githooks/pre-commit` blocks credentials, conflict markers and >20 MB files,
verifies the `.opencode/*` redirect stubs, and runs framework validation plus
workflow integrity — or plugin tests — when their paths are staged

## Notes

Expand Down
24 changes: 24 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ Examples:
- `fix(agents): correct model prefix for director agents`
- `docs: add model assignment strategy section`

### Git hooks

These rules are enforced locally by tracked hooks in `.githooks/`:

- **`commit-msg`** — rejects non-conventional messages: unknown or uppercase type,
empty or malformed scope, trailing period, header over 100 characters, and a `!`
header without a `BREAKING CHANGE:` footer. Git-generated `Merge …`, `Revert "…"`,
`fixup!`/`squash!` and `Initial commit` messages are exempt.
- **`pre-commit`** — blocks staged credentials, unresolved conflict markers and files
over 20 MB, checks that the `.opencode/*` redirect stubs still resolve, and runs
`tests/agents/validate.mjs --no-report` plus `tests/workflow/run-all.mjs` when
`.agents/`, `tests/agents/` or `AGENTS.md` is staged — or `npm run test:plugins`
when `.opencode/plugins/` is staged.

Install once per clone (also runs on `npm install` via `prepare`):

```bash
npm run hooks:install # sets core.hooksPath=.githooks
```

Bypass a single commit with `git commit --no-verify`. Skip only the framework/plugin
gates with `OCGS_SKIP_HOOK_CHECKS=1 git commit …`; the secret scan always runs.
Hook behavior is covered by `npm run test:hooks`.

## Pull Request Process

- Keep PRs focused — one feature/fix per PR
Expand Down
2 changes: 1 addition & 1 deletion docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ All framework changes go through pull requests to `development`:

1. **Branch from `development`**: `feature/{issue-number}-{short-name}`
2. **One issue per branch**: Each branch addresses exactly one issue
3. **Commit convention**: Conventional Commits (`feat:`, `fix:`, `docs:`, etc.)
3. **Commit convention**: Conventional Commits (`feat:`, `fix:`, `docs:`, etc.) — enforced by `.githooks/commit-msg` (`npm run hooks:install`)
4. **Close issue reference**: Include `Closes #N` in the commit message
5. **CI must pass**: Agent validation, workflow integrity, and plugin tests
6. **Merge to `development`**: Fast-forward merge, push, close issue
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
"test": "tests"
},
"scripts": {
"test": "node --import tsx --test .opencode/plugins/tests/test-*.mjs",
"prepare": "node tools/git-hooks/install.mjs",
"hooks:install": "node tools/git-hooks/install.mjs",
"test": "npm run test:plugins && npm run test:hooks",
"test:plugins": "node --import tsx --test .opencode/plugins/tests/test-*.mjs",
"test:hooks": "node --test tests/git-hooks/test-commit-msg.mjs tests/git-hooks/test-pre-commit.mjs",
"test:framework": "node tests/agents/validate.mjs",
"test:parity": "node --import tsx --test tests/e2e/test-parity.test.ts"
},
Expand Down
8 changes: 5 additions & 3 deletions tests/agents/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -510,10 +510,12 @@ function main() {
// Print to console
console.log(report);

// Write report file
// Write report file (skipped from git hooks: hooks must not mutate the work tree)
const reportPath = join(ROOT, "tests", "agents", "validation-report.md");
writeFileSync(reportPath, report);
console.log(`📄 Report saved to: tests/agents/validation-report.md`);
if (!process.argv.includes("--no-report")) {
writeFileSync(reportPath, report);
console.log(`📄 Report saved to: tests/agents/validation-report.md`);
}

if (!passed) {
console.log(
Expand Down
204 changes: 204 additions & 0 deletions tests/git-hooks/test-commit-msg.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/**
* Tests for tools/git-hooks/validate-commit-msg.mjs (Conventional Commits gate).
*/

import { execFileSync } from "node:child_process"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { strict as assert } from "node:assert"
import { describe, it } from "node:test"

import {
MAX_HEADER_LENGTH,
validateCommitMessage,
} from "../../tools/git-hooks/validate-commit-msg.mjs"

const SCRIPT = resolve(import.meta.dirname, "../../tools/git-hooks/validate-commit-msg.mjs")

const errorsFor = (message) => validateCommitMessage(message).errors
const warningsFor = (message) => validateCommitMessage(message).warnings

describe("commit-msg: accepted messages", () => {
it("accepts a plain conventional message", () => {
const result = validateCommitMessage("fix: correct model prefix for director agents")
assert.deepEqual(result.errors, [])
assert.equal(result.exempt, false)
})

it("accepts a scoped message with a PR reference", () => {
assert.deepEqual(errorsFor("feat(plugin): add session-start time tracking (#109)"), [])
})

it("accepts a breaking change that documents the break", () => {
const message = [
"feat(plugin)!: drop OpenCode 1.x plugin support",
"",
"BREAKING CHANGE: plugins now use the V2 id/setup contract.",
].join("\n")
assert.deepEqual(errorsFor(message), [])
})

it("accepts bodies that start with markdown headings", () => {
const message = ["chore: prepare v0.13.1", "", "## Summary", "- changelog entry", "", "Closes #109"].join("\n")
assert.deepEqual(errorsFor(message), [])
})

it("accepts footers and multiple body paragraphs", () => {
const message = ["docs(agents): document delegation rules", "", "Explains escalation.", "", "Refs: docs/architecture/ADR-004.md"].join("\n")
assert.deepEqual(errorsFor(message), [])
})
})

describe("commit-msg: rejected messages", () => {
it("rejects a non-conventional header", () => {
const errors = errorsFor("Update stuff")
assert.equal(errors.length, 1)
assert.match(errors[0], /does not match/)
})

it("rejects an unknown type", () => {
assert.match(errorsFor("feature: add thing").join("\n"), /Unknown type "feature"/)
})

it("rejects an uppercase type", () => {
assert.match(errorsFor("FEAT: add thing").join("\n"), /must be lowercase/)
})

it("rejects an empty scope", () => {
assert.match(errorsFor("feat(): add thing").join("\n"), /Scope is empty/)
})

it("rejects an uppercase or spaced scope", () => {
assert.match(errorsFor("feat(Plugin): add thing").join("\n"), /Scope "Plugin"/)
assert.match(errorsFor("feat(open code): add thing").join("\n"), /does not match|Scope/)
})

it("rejects a trailing period in the summary", () => {
assert.match(errorsFor("fix: correct the thing.").join("\n"), /must not end with a period/)
})

it("rejects an over-long header", () => {
const header = `fix: ${"a".repeat(MAX_HEADER_LENGTH)}`
assert.match(errorsFor(header).join("\n"), /maximum is 100/)
})

it("rejects a body without a separating blank line", () => {
assert.match(errorsFor("feat: add thing\ndetails here").join("\n"), /blank line/)
})

it("rejects a bare breaking marker without a BREAKING CHANGE footer", () => {
assert.match(errorsFor("feat!: drop v1 support").join("\n"), /BREAKING CHANGE/)
})

it("rejects an empty message", () => {
assert.deepEqual(errorsFor(""), ["Empty commit message."])
})

it("treats a comments-only message as empty", () => {
const message = ["# Please enter the commit message", "#", "# On branch master"].join("\n")
assert.deepEqual(errorsFor(message), ["Empty commit message."])
})

it("rejects a summary that is only whitespace after the colon", () => {
assert.match(errorsFor("feat: ").join("\n"), /does not match|Summary must not be empty/)
})
})

describe("commit-msg: generated messages are exempt", () => {
for (const message of [
'Merge branch \'development\' into master',
'Merge pull request #108 from striderZA/fix/v2',
'Revert "feat(plugin): add thing"',
"fixup! fix: correct the thing",
"squash! fix: correct the thing",
"Initial commit",
]) {
it(`exempts: ${message}`, () => {
const result = validateCommitMessage(message)
assert.equal(result.exempt, true)
assert.deepEqual(result.errors, [])
})
}
})

describe("commit-msg: advisory warnings", () => {
it("warns when no issue or design reference is present", () => {
assert.match(warningsFor("fix: correct the thing").join("\n"), /No issue\/design reference/)
})

it("does not warn when a reference is present", () => {
assert.equal(warningsFor("fix: correct the thing (#42)").some((w) => /No issue/.test(w)), false)
})

it("warns about sentence-case summaries", () => {
assert.match(warningsFor("fix: Correct the thing").join("\n"), /lowercase/)
})

it("warns when the header is longer than 72 chars but under the limit", () => {
const header = `fix: ${"a".repeat(70)}`
const warnings = warningsFor(header).join("\n")
assert.match(warnings, /72 or fewer/)
assert.deepEqual(errorsFor(header), [])
})

it("warns about over-long body lines", () => {
const message = ["fix: correct the thing", "", "b".repeat(101)].join("\n")
assert.match(warningsFor(message).join("\n"), /wrap prose at 100/)
})
})

describe("commit-msg: scissors block", () => {
it("ignores everything after the scissors line", () => {
const message = [
"fix: correct the thing",
"",
"# ------------------------ >8 ------------------------",
"diff --git a/x b/x",
"this would not parse as a body",
].join("\n")
assert.deepEqual(errorsFor(message), [])
})
})

describe("commit-msg: CLI", () => {
it("exits 1 and prints guidance for a bad message", () => {
const dir = mkdtempSync(join(tmpdir(), "ocgs-msg-"))
const file = join(dir, "msg.txt")
writeFileSync(file, "bad message\n", "utf8")

let status = 0
let stderr = ""
try {
execFileSync(process.execPath, [SCRIPT, file], { encoding: "utf8", stdio: "pipe" })
} catch (error) {
status = error.status
stderr = String(error.stderr)
}

assert.equal(status, 1)
assert.match(stderr, /Commit message rejected/)
assert.match(stderr, /CONTRIBUTING\.md#commit-conventions/)
rmSync(dir, { recursive: true, force: true })
})

it("exits 0 for a valid message", () => {
const dir = mkdtempSync(join(tmpdir(), "ocgs-msg-"))
const file = join(dir, "msg.txt")
writeFileSync(file, "test(hooks): cover the commit-msg gate (#1)\n", "utf8")

const stdout = execFileSync(process.execPath, [SCRIPT, file], { encoding: "utf8" })
assert.match(stdout, /follows Conventional Commits/)
rmSync(dir, { recursive: true, force: true })
})

it("exits 2 when the message file is missing from the command line", () => {
let status = 0
try {
execFileSync(process.execPath, [SCRIPT], { encoding: "utf8", stdio: "pipe" })
} catch (error) {
status = error.status
}
assert.equal(status, 2)
})
})
Loading
Loading