This file provides guidance to Claude Code and other AI coding agents when working with code in this repository.
commitment is an AI-powered commit message generator. It uses AI agents (Claude CLI, Codex CLI, or Gemini CLI) to generate high-quality, conventional commit messages from git diffs.
Architecture Philosophy: Selective abstraction. Simple base class (≤3 extension points) + pure utilities, but no factories or provider chains. Agents extend BaseAgent (~40-60 LOC each). One-command setup with commitment init for automatic hook installation.
Constitution: This project follows @docs/constitutions/current/ (v3). See current/meta.md for version history.
Automatic setup: When opening this repository in Claude Code Web, plugins are installed automatically via SessionStart hook.
The .claude/settings.json hook configuration runs .claude/setup-plugins.sh which:
- Checks if running in Claude Code Web (
CLAUDE_CODE_REMOTE=true) - Adds custom marketplaces (spectacular, superpowers-marketplace)
- Updates marketplaces to latest versions
- Installs required plugins:
- spectacular@spectacular - Spec-anchored development with stacked branches
- superpowers@superpowers-marketplace - Skills and workflows for Claude Code
Manual setup (local): Run bun run setup:claude-plugins (note: will skip if not in Claude Code Web)
Individual commands: See .claude/setup-plugins.sh for the full installation sequence.
# Build the project
bun run build
# Watch mode development
bun run dev
# Clean build artifacts
bun run clean# Run linting (check-types + format-check + biome)
bun run lint
# Fix linting issues automatically
bun run lint:fix
# Format code
bun run format
# Check formatting
bun run format:check
# Type checking
bun run check-types# Run tests
bun test
# Watch mode testing
bun test --watch
# Run tests with coverage
bun test --coverageThe eval system compares Claude vs Codex commit message quality using ChatGPT as a judge. This is NOT a test - it's a standalone evaluation script that makes real API calls.
# Run all mocked fixtures
bun run eval
# Run specific fixture
bun run eval:fixture simple
# Run with live git changes
bun run eval:liveStructure:
src/eval/run-eval.ts- Main entry point (standalone script)src/eval/runner.ts- Orchestrates fixture → generation → evaluation pipelinesrc/eval/evaluator.ts- Wraps ChatGPT agent, calculates scoressrc/eval/chatgpt-agent.ts- ChatGPT agent using OpenAI Agents SDKsrc/eval/reporter.ts- Formats and stores resultssrc/eval/schemas.ts- Zod schemas for type safetysrc/eval/fixtures/- Test fixtures (simple, complex, etc.).eval-results/- Generated results (gitignored)
Results:
- Timestamped JSON files:
simple-2025-10-23T12-34-56.789Z.json - Symlinks to latest:
latest-simple.json - Markdown reports:
latest-report.md
OpenAI Agents SDK Pattern:
import { Agent, run } from '@openai/agents';
import { z } from 'zod';
// Define structured output schema
const schema = z.object({
score: z.number().min(0).max(10),
feedback: z.string(),
});
// Create agent with outputType
const agent = new Agent({
name: 'Evaluator',
instructions: 'Evaluate on scale 0-10...',
model: 'gpt-5', // Always use gpt-5 for OpenAI SDKs
outputType: schema,
});
// Run and access structured output
const result = await run(agent, 'Your prompt here');
const output = result.finalOutput; // Typed as z.infer<typeof schema>Key principles:
- Use
outputTypewith Zod schema, NOT tools for structured output - Access data via
result.finalOutput, NOTresult.toolCalls - Always use
gpt-5as the model name for OpenAI Agents SDK - Standalone script, not
bun test(avoids expensive API calls in test suite)
IMPORTANT: When working on tickets/issues in this repository, use git-spice for branch and commit management instead of standard git commit.
git-spice enables stacked branch workflows, making it easy to:
- Create branches stacked on top of each other
- Submit multiple related PRs in a stack
- Automatically restack branches when changes are made
- Generate branch names from commit messages
-
Create a new branch for your ticket (stacked on current branch):
gs branch create <branch-name> # or let git-spice generate the name from your commit message: gs bc
-
Make your changes and commit (use commitment itself!):
# Stage your changes git add . # Let commitment generate the message ./dist/cli.js
-
Create another stacked branch for the next ticket:
# Creates a branch on top of the current one gs bc feat-2 -
Restack after changes (if you modify an earlier branch):
# Restack all branches in the current stack gs stack restack # Or restack just the current branch gs branch restack
-
Submit pull requests for the entire stack:
gs stack submit
gs branch create(alias:gs bc) - Create a new stacked branchgs branch track- Track an existing branch in the stackgs branch squash- Squash commits in the current branchgs stack restack- Rebase all branches in the stackgs upstack restack- Restack branches upstack from currentgs stack submit- Submit PRs for all branches in stack
# Start from main
git checkout main
# Ticket 1: Add feature X
gs bc add-feature-x
# Make changes, commit with commitment
git add .
./dist/cli.js
# Ticket 2: Add feature Y (stacked on feature X)
gs bc add-feature-y
# Make changes, commit
git add .
./dist/cli.js
# Ticket 3: Add tests (stacked on feature Y)
gs bc add-tests
# Make changes, commit
git add .
./dist/cli.js
# Submit all PRs in the stack
gs stack submitIf you need to modify Ticket 1 after creating Tickets 2 and 3:
git checkout add-feature-x
# Make changes
git add .
git commit --amend --no-edit
# Restack everything
gs stack restack- Use strict TypeScript with all strict flags enabled
- All public functions must have explicit return types
- No
anytypes allowed - Use
const assertionsandas constfor immutable data - Follow naming conventions:
camelCasefor functions and variablesPascalCasefor typeskebab-casefor files- Leading underscore for private members (e.g.,
_privateMethod)
The project uses strict ESLint rules including:
- TypeScript strict rules
- Import organization (external → internal)
- Promise best practices
- Unicorn rules for modern JavaScript
- Sorted imports and exports
Always run bun run lint:fix before committing.
Imports must be organized in this order:
- External dependencies
- Internal imports (use named exports only)
Example:
import chalk from 'chalk';
import { execa } from 'execa';
import { CommitMessageGenerator } from './generator.js';
import { hasContent } from './utils/guards.js';See @docs/constitutions/current/architecture.md for full architectural rules and boundaries.
- CLI (
src/cli.ts): Command-line interface (~200 lines)- Init Command (
src/cli/commands/init.ts): Automatic hook installation with auto-detection - CLI Schemas (
src/cli/schemas.ts): CLI option validation
- Init Command (
- Generator (
src/generator.ts): CommitMessageGenerator class for AI-powered generation - Agents (
src/agents/): AI agent implementations extending BaseAgentbase-agent.ts- Abstract base class with template pattern (~80 LOC)claude.ts- Claude CLI agent (~40-60 LOC)codex.ts- Codex CLI agent (~40-60 LOC)gemini.ts- Gemini CLI agent (~40-60 LOC)factory.ts- Simple agent factory with ts-pattern (~30 LOC)types.ts- Agent interface and types
- Prompts (
src/prompts/): Prompt generation modulecommit-message-prompt.ts- Pure functions for building promptsindex.ts- Barrel exports
- Schemas (
src/types/schemas.ts,src/cli/schemas.ts,src/utils/git-schemas.ts): Zod schemas for runtime validation - Git Utilities (
src/utils/git-schemas.ts): Git output parsing and file categorizationparseGitStatus()- Parse and validate git status outputcategorizeFiles()- Categorize files by type (tests, components, configs, etc.)analyzeChanges()- Extract change statistics (added, modified, deleted, renamed)
- Guards (
src/utils/guards.ts): Type guard utilities for safer code
- AI-Only Architecture: All commit messages generated by AI agents (Claude, Codex, or Gemini)
- Conventional Commits: All messages follow conventional commit format
- Template Method Pattern: BaseAgent with ≤3 extension points, agents extend with minimal code (~40-60 LOC)
- Simple Factory: Type-safe agent instantiation with ts-pattern for exhaustiveness checking
- One-Command Setup:
commitment initauto-detects and installs hooks - ESM-Only: Built as ESM modules using latest TypeScript and Node.js features
- Strict TypeScript: All strict compiler options enabled
- Self-Dogfooding: commitment uses itself via git hooks
- Schema-First Type Safety: Runtime validation at system boundaries using Zod schemas
- Cross-Platform: LF line endings via
.gitattributes, Windows-compatible hooks
commitment uses a schema-first approach to type safety, combining TypeScript's compile-time checks with Zod's runtime validation. This ensures data integrity at system boundaries while maintaining excellent developer experience.
Core Principle: Define schemas once, derive types automatically, validate at boundaries.
Benefits:
- Single Source of Truth: Schemas define both runtime validation and TypeScript types
- Runtime Safety: Catch invalid data from users, files, git, or external systems
- Better Errors: Zod provides detailed, actionable error messages
- Type Inference: TypeScript types are automatically inferred from schemas
- Consistency: Same validation logic in development and production
Schemas are organized by domain:
src/
├── types/schemas.ts # Core domain types (CommitTask, CommitMessageOptions, GeneratorConfig)
├── cli/schemas.ts # CLI-specific types (CliOptions, provider config parsing)
├── utils/git-schemas.ts # Git output parsing (GitStatus, FileCategories)
└── providers/types.ts # Provider configurations (ProviderConfig, CLIProviderConfig, APIProviderConfig)
Rule: Validate data at system boundaries (user input, external commands, file I/O), trust it internally.
Boundaries in commitment:
- CLI argument parsing
- Generator construction
- Provider configuration
- Git command output
- JSON parsing
Example: CLI Validation
import { validateCliOptions } from './cli/schemas.js';
// Boundary: User input from sade CLI
const rawOptions = /* parsed by sade */;
// Validate immediately
const options = validateCliOptions(rawOptions);
// Now options is fully typed with defaults applied
// Use validated data internally (no need to re-validate)
const generator = new CommitMessageGenerator({
agent: options.agent,
workdir: options.cwd,
});Example: Git Output Validation
import { parseGitStatus } from './utils/git-schemas.js';
// Boundary: External git command
const { stdout } = await execa('git', ['status', '--porcelain'], { cwd });
// Parse and validate git output
const status = parseGitStatus(stdout);
// status is now typed as GitStatus with validated structure
// Use internally without re-validation
if (status.hasChanges) {
const files = status.stagedFiles; // string[] guaranteed
}Guidelines:
- Use descriptive field names and add JSDoc comments
- Include validation constraints (min, max, positive, etc.)
- Provide clear error messages
- Use .default() for optional fields with defaults
- Use .transform() for data normalization
Example: Well-Defined Schema
import { z } from 'zod';
/**
* Schema for commit task validation
*
* @example
* ```typescript
* const task = {
* title: 'Add feature',
* description: 'Implement new feature',
* produces: ['src/feature.ts']
* };
* const validated = validateCommitTask(task);
* ```
*/
export const commitTaskSchema = z.object({
/**
* Short, descriptive title of the task
*/
title: z
.string()
.min(1, 'Task title must not be empty')
.max(200, 'Task title must not exceed 200 characters'),
/**
* Detailed description of what the task accomplishes
*/
description: z
.string()
.min(1, 'Task description must not be empty')
.max(1000, 'Task description must not exceed 1000 characters'),
/**
* List of files or outputs produced by this task
*/
produces: z.array(z.string()).min(0).default([]),
});
// Type is automatically inferred
export type CommitTask = z.infer<typeof commitTaskSchema>;
// Validation function with clear signature
export function validateCommitTask(task: unknown): CommitTask {
return commitTaskSchema.parse(task);
}Two approaches: Throwing vs. Safe parsing
Throwing (use for critical validation):
// Will throw ZodError if invalid
const config = validateGeneratorConfig(userInput);Safe parsing (use for optional validation or user-facing errors):
const result = safeValidateGeneratorConfig(userInput);
if (result.success) {
// result.data is validated config
console.log('Valid config:', result.data);
} else {
// result.error is ZodError with details
console.error('Validation failed:', result.error.issues);
}Example: User-Friendly CLI Errors
import { z } from 'zod';
import chalk from 'chalk';
import { validateCliOptions, formatValidationError } from './cli/schemas.js';
try {
const options = validateCliOptions(rawOptions);
// Proceed with valid options
} catch (error) {
if (error instanceof z.ZodError) {
// Format errors for users
console.error(chalk.red('Invalid options:'));
console.error(formatValidationError(error));
console.log(chalk.gray('\nRun --help for usage information'));
process.exit(1);
}
throw error;
}Use schemas for: Validating unknown/external data Use type guards for: Narrowing known types
Example: Combining Both
import { isCLIProviderConfig } from './providers/types.js';
import { validateProviderConfig } from './providers/types.js';
// 1. Validate unknown data (boundary)
const config = validateProviderConfig(userInput);
// config is now ProviderConfig (CLIProviderConfig | APIProviderConfig)
// 2. Narrow validated type (internal logic)
if (isCLIProviderConfig(config)) {
// TypeScript knows config.command exists
console.log('CLI command:', config.command);
}Type Guards in guards.ts:
import { hasContent, isNonEmptyArray, isDefined } from './utils/guards.js';
// String validation
const input: string | null = getUserInput();
if (hasContent(input)) {
// input is string, not null/undefined/empty
console.log(input.toUpperCase());
}
// Array validation with type narrowing
const items: string[] = getItems();
if (isNonEmptyArray(items)) {
// items is [string, ...string[]]
const first = items[0]; // No undefined check needed!
}
// Defined check
const value: string | null | undefined = getValue();
if (isDefined(value)) {
// value is string
console.log(value.length);
}Strategy: Build complex schemas from simple, reusable pieces.
Example: Provider Schema Composition
import { z } from 'zod';
// Base schema with common fields
const baseProviderSchema = z.object({
timeout: z.number().positive().optional(),
});
// CLI-specific schema extends base
export const cliProviderSchema = baseProviderSchema.extend({
type: z.literal('cli'),
provider: z.enum(['claude', 'codex', 'gemini']),
command: z.string().optional(),
args: z.array(z.string()).optional(),
});
// API-specific schema extends base
export const apiProviderSchema = baseProviderSchema.extend({
type: z.literal('api'),
provider: z.enum(['openai', 'gemini']),
apiKey: z.string().min(1),
endpoint: z.string().url().optional(),
});
// Discriminated union for type-safe handling
export const providerConfigSchema = z.discriminatedUnion('type', [
cliProviderSchema,
apiProviderSchema,
]);Use .refine() for custom validation logic that spans multiple fields.
Example: Mutual Exclusivity
export const generatorConfigSchema = z
.object({
provider: z
.union([
/* ... */
])
.optional(),
providerChain: z.array(/* ... */).optional(),
})
.refine(
(data) => {
// Ensure provider and providerChain are mutually exclusive
const hasProvider = data.provider !== undefined;
const hasProviderChain = data.providerChain !== undefined;
return !(hasProvider && hasProviderChain);
},
{
message: 'Cannot specify both "provider" and "providerChain"',
path: ['provider'],
},
);Use .transform() to normalize data during validation.
Example: Git Status Line Parsing
export const gitStatusLineSchema = z
.string()
.min(4)
.refine((line) => /^[ !?ACDMRU]{2}$/.test(line.slice(0, 2)))
.transform((line) => {
const statusCode = line.slice(0, 2);
const filename = line.slice(3);
const isStaged = !statusCode.startsWith('?') && !statusCode.startsWith(' ');
return {
statusCode,
filename,
isStaged,
stagedStatus: statusCode[0],
unstagedStatus: statusCode[1],
};
});
// Input: "M src/file.ts"
// Output: { statusCode: "M ", filename: "src/file.ts", isStaged: true, ... }Strategy: Use .default() and .optional() to handle missing data gracefully.
export const cliOptionsSchema = z.object({
ai: z.boolean().default(true), // Defaults to true
cwd: z.string().default(process.cwd()), // Defaults to current dir
provider: z.string().optional(), // Undefined if not provided
timeout: z.string().default('120000'), // Defaults to 120s
});
const options = validateCliOptions({});
// options.ai === true (default applied)
// options.cwd === process.cwd() (default applied)
// options.provider === undefined (optional)Before (pure TypeScript):
// types.ts
export type ProviderConfig = {
type: 'cli' | 'api';
provider: string;
timeout?: number;
};
// usage.ts
function createProvider(config: ProviderConfig) {
// No runtime validation!
// config.timeout could be negative, NaN, etc.
}After (schema-first with Zod):
// types.ts
import { z } from 'zod';
export const providerConfigSchema = z.object({
type: z.enum(['cli', 'api']),
provider: z.string().min(1),
timeout: z.number().positive().optional(),
});
// Type is inferred from schema
export type ProviderConfig = z.infer<typeof providerConfigSchema>;
// Validation helper
export function validateProviderConfig(config: unknown): ProviderConfig {
return providerConfigSchema.parse(config);
}
// usage.ts
function createProvider(config: unknown) {
// Validate at boundary
const validated = validateProviderConfig(config);
// Now guaranteed: timeout is positive or undefined
// Now guaranteed: provider is non-empty string
}When migrating a type to schema-first:
- Create the schema in appropriate file (
src/types/schemas.ts,src/cli/schemas.ts, etc.) - Add validation constraints (min, max, positive, etc.)
- Infer the type using
z.infer<typeof schema> - Create validation function (both throwing and safe variants)
- Add JSDoc with examples
- Update imports to use inferred types
- Add validation at boundaries
- Write tests for schema validation
- Update documentation (this file!)
Schema tests should cover:
- Valid inputs (success cases)
- Invalid inputs (error cases)
- Edge cases (empty, null, undefined, boundary values)
- Error messages (ensure they're helpful)
Example Test:
import { describe, expect, it } from 'bun:test';
import { validateCommitTask, commitTaskSchema } from '../types/schemas.js';
describe('commitTaskSchema', () => {
it('accepts valid task', () => {
const task = {
title: 'Add feature',
description: 'Implement new feature',
produces: ['src/feature.ts'],
};
const result = validateCommitTask(task);
expect(result).toEqual(task);
});
it('rejects empty title', () => {
const task = {
title: '',
description: 'Desc',
produces: [],
};
expect(() => validateCommitTask(task)).toThrow('Task title must not be empty');
});
it('applies default for produces', () => {
const task = {
title: 'Test',
description: 'Desc',
// produces omitted
};
const result = commitTaskSchema.parse(task);
expect(result.produces).toEqual([]);
});
it('rejects title exceeding max length', () => {
const task = {
title: 'x'.repeat(201),
description: 'Desc',
produces: [],
};
expect(() => validateCommitTask(task)).toThrow('must not exceed 200 characters');
});
});Zod validation is fast, but follow these guidelines:
- Validate once at boundaries, cache the result
- Don't re-validate internal data that's already typed
- Use .safeParse() for non-critical validation
- Avoid validation in loops when possible
Example: Validate Once
class CommitMessageGenerator {
private validatedConfig: CommitMessageGeneratorConfig;
constructor(config: unknown) {
// Validate once in constructor
this.validatedConfig = validateGeneratorConfig(config);
}
// Use cached validated config everywhere else
async generate(options: CommitMessageOptions): Promise<string> {
// No re-validation needed - generator is AI-only
const agent = this.createAgent(this.validatedConfig.agent);
return await agent.generate(prompt, this.validatedConfig.workdir);
}
}Parse unknown data:
const validated = schema.parse(unknownData); // Throws on errorSafe parse with error handling:
const result = schema.safeParse(unknownData);
if (result.success) {
/* use result.data */
} else {
/* handle result.error */
}Narrow discriminated union:
if (config.type === 'cli') {
// TypeScript knows config is CLIProviderConfig
}Check non-empty array:
if (isNonEmptyArray(items)) {
const first = items[0]; // No undefined!
}Check string content:
if (hasContent(str)) {
console.log(str.toUpperCase()); // Not null/undefined/empty
}- Zod Documentation: https://zod.dev
- Type Guards: See
src/utils/guards.tsfor examples - Schema Files: Explore
src/types/schemas.ts,src/cli/schemas.ts,src/utils/git-schemas.ts - Provider Types: See
src/providers/types.tsfor advanced patterns
See @docs/constitutions/current/architecture.md for full extension point documentation.
The agent system uses selective abstraction - simple base class (≤3 extension points), no factories, no auto-detection. Each agent extends BaseAgent (~40-60 LOC) implementing only executeCommand() with all flow inherited.
Create a new file in src/agents/:
import { execa } from 'execa';
import { AgentError } from '../errors.js';
import type { Agent } from './types.js';
/**
* My AI agent for generating commit messages
*
* Implements the Agent interface with CLI execution logic inlined.
* No base classes - all logic is self-contained.
*
* @example
* ```typescript
* const agent = new MyAgent();
* const message = await agent.generate(prompt, '/path/to/repo');
* ```
*/
export class MyAgent implements Agent {
readonly name = 'my-cli';
async generate(prompt: string, workdir: string): Promise<string> {
// 1. Check CLI availability
try {
await execa('which', ['my-cli']);
} catch {
throw AgentError.cliNotFound('my-cli', 'My CLI');
}
// 2. Execute CLI with prompt
let result;
try {
result = await execa('my-cli', ['--prompt', prompt], {
cwd: workdir,
timeout: 120_000,
});
} catch (error) {
const exitCode = error instanceof Error && 'exitCode' in error ? error.exitCode : 'unknown';
const stderr = error instanceof Error && 'stderr' in error ? error.stderr : 'Unknown error';
throw AgentError.executionFailed('My CLI', exitCode, stderr as string, error as Error);
}
// 3. Parse and validate response
const output = result.stdout.trim();
if (!output || !output.includes(':')) {
throw AgentError.malformedResponse('My CLI', output);
}
return output;
}
}Add your agent to the AgentName type in src/agents/types.ts:
export type AgentName = 'claude' | 'codex' | 'my-cli';Add agent instantiation in src/generator.ts:
// In generateWithAI() method
if (this.config.agent === 'claude') {
agent = new ClaudeAgent();
} else if (this.config.agent === 'codex') {
agent = new CodexAgent();
} else if (this.config.agent === 'my-cli') {
agent = new MyAgent();
}Add to src/agents/index.ts:
export { MyAgent } from './my-agent.js';Create src/agents/__tests__/my-agent.test.ts:
import { describe, expect, it, mock } from 'bun:test';
import { MyAgent } from '../my-agent.js';
describe('MyAgent', () => {
it('should have correct name', () => {
const agent = new MyAgent();
expect(agent.name).toBe('my-cli');
});
it('should generate commit message', async () => {
const agent = new MyAgent();
// Mock CLI execution and test success case
});
it('should throw when CLI not found', async () => {
const agent = new MyAgent();
// Mock missing CLI and verify error
});
});Add your agent to the help text in src/cli.ts:
.option('--agent <name>', 'AI agent to use: claude, codex, my-cli (default: "claude")', 'claude')That's it! Your agent is now fully integrated and can be used with --agent my-cli.
- No base classes: Each agent implements the
Agentinterface directly - Inline logic: All CLI execution and parsing logic is in the agent class (~50-100 LOC)
- Self-contained: No shared utilities or factories - just the agent class
- Simple configuration: Just an agent name string, no complex configs
- Actionable errors: All errors follow "what, why, how-to-fix" pattern using
AgentError
commitment uses itself for its own commit messages via lefthook:
- pre-commit: Runs linting and builds dist/ (configured in
lefthook.yml) - prepare-commit-msg: Calls
./dist/cli.js --message-onlyto generate commit message
This ensures commitment is battle-tested on itself and provides a real-world example. See lefthook.yml in the project root.
See @docs/constitutions/current/architecture.md for CLI module boundaries and responsibilities.
The CLI is simplified to core functionality - no complex command modules, no provider chains, no auto-detection.
src/cli/
├── cli.ts # Main CLI entry point (~200 lines) with commands
├── schemas.ts # CLI option validation with Zod
└── commands/
├── init.ts # Hook installation command (~250 LOC)
└── index.ts # Command exports
Main Command: Generate and create commit
npx commitment [options]Init Command: Set up git hooks automatically
npx commitment init [options]Main Command Flags:
--agent <name>- AI agent to use: claude, codex, gemini (default: "claude")--dry-run- Generate message without creating commit--message-only- Output only the commit message (implies--quiet, no commit)--quiet- Suppress progress messages (useful for scripting)--verbose- Show detailed debug output--cwd <path>- Working directory (default: current directory)
Init Command Flags:
--hook-manager <type>- Hook manager: lefthook, husky, simple-git-hooks, plain--cwd <path>- Working directory (default: current directory)
The CLI file uses relaxed ESLint rules since it needs console.log and process.exit:
/* eslint-disable no-console, unicorn/no-process-exit */
import chalk from 'chalk';
// Console and process.exit are allowed in CLI files
console.log(chalk.green('✅ Commit created'));
process.exit(0);The project uses bun:test for all testing with comprehensive coverage:
- Co-located tests: Unit tests live alongside source files in
__tests__/directories - Integration tests: Located in
src/__tests__/integration/ - Test patterns: All public APIs, edge cases, error handling, and validation
src/
├── __tests__/
│ └── integration/ # Integration tests
│ ├── validation.test.ts # Cross-module validation tests
│ └── error-messages.test.ts # User-facing error tests
├── cli/
│ ├── __tests__/
│ │ ├── schemas.test.ts
│ │ └── provider-config-builder.test.ts
│ └── commands/__tests__/
│ ├── list-providers.test.ts
│ ├── check-provider.test.ts
│ └── auto-detect.test.ts
├── providers/
│ ├── __tests__/ # Provider core tests
│ ├── implementations/__tests__/ # Provider implementation tests
│ └── utils/__tests__/ # Provider utility tests
├── types/__tests__/
│ └── schemas.test.ts
└── utils/__tests__/
├── guards.test.ts
└── git-schemas.test.ts
# Run all tests
bun test
# Run specific test file
bun test src/cli/__tests__/schemas.test.ts
# Run tests in watch mode
bun test --watch
# Run tests with coverage
bun test --coverageSchema Validation Tests:
describe('mySchema', () => {
it('accepts valid input', () => {
const valid = { field: 'value' };
expect(() => validateMySchema(valid)).not.toThrow();
});
it('rejects invalid input', () => {
const invalid = { field: 123 };
expect(() => validateMySchema(invalid)).toThrow(ZodError);
});
it('applies defaults', () => {
const partial = {};
const result = mySchema.parse(partial);
expect(result.field).toBe('default');
});
});Provider Tests:
describe('MyProvider', () => {
it('should check availability', async () => {
const provider = new MyProvider();
const available = await provider.isAvailable();
expect(typeof available).toBe('boolean');
});
it('should generate commit message', async () => {
const provider = new MyProvider();
const message = await provider.generateCommitMessage('prompt', {
workdir: '/tmp',
});
expect(message).toBeTruthy();
});
});CLI Command Tests (see "Testing CLI Commands" section above)
See @docs/constitutions/current/architecture.md for module organization rules.
src/
├── cli.ts # CLI entry point (~200 lines)
├── generator.ts # CommitMessageGenerator class
├── errors.ts # Consolidated error types (AgentError, GeneratorError)
├── index.ts # Public API exports (≤10 items)
├── agents/ # Agent system with BaseAgent template pattern
│ ├── types.ts # Agent interface and types
│ ├── base-agent.ts # Abstract base class (~80 LOC)
│ ├── factory.ts # Simple agent factory with ts-pattern (~30 LOC)
│ ├── claude.ts # Claude agent (~40-60 LOC)
│ ├── codex.ts # Codex agent (~40-60 LOC)
│ ├── gemini.ts # Gemini agent (~40-60 LOC)
│ └── index.ts # Agent exports
├── cli/ # CLI modules
│ ├── schemas.ts # CLI option validation
│ ├── helpers.ts # Display and execution helpers (~80 LOC)
│ └── commands/
│ ├── init.ts # Hook installation command
│ └── index.ts # Command exports
├── prompts/ # Prompt generation module
│ ├── commit-message-prompt.ts # Pure prompt building functions
│ ├── index.ts # Barrel exports
│ └── __tests__/
│ └── commit-message-prompt.test.ts
├── eval/ # Evaluation system (standalone, not tests)
│ ├── run-eval.ts # Main entry point script
│ ├── runner.ts # Pipeline orchestration
│ ├── evaluator.ts # ChatGPT evaluation wrapper
│ ├── chatgpt-agent.ts # OpenAI Agents SDK integration
│ ├── reporter.ts # Result formatting and storage
│ ├── schemas.ts # Eval-specific Zod schemas
│ ├── index.ts # Public exports
│ ├── fixtures/ # Test fixtures for evaluation
│ │ ├── simple/ # Simple bug fix fixture
│ │ │ ├── metadata.json # Fixture metadata
│ │ │ ├── mock-status.txt # Mocked git status
│ │ │ └── mock-diff.txt # Mocked git diff
│ │ └── complex/ # Complex feature fixture
│ └── results/ # Generated results (gitignored)
├── types/ # Core type definitions
│ └── schemas.ts # Zod schemas for core types
└── utils/ # Shared utilities
├── guards.ts # Type guard utilities
└── git-schemas.ts # Git output validation
examples/
├── git-hooks/ # Plain git hooks examples
├── lefthook/ # Lefthook integration examples
├── husky/ # Husky integration examples (legacy)
├── simple-git-hooks/ # simple-git-hooks integration examples
├── lint-staged/ # lint-staged with lefthook examples
└── global-install/ # Global install examples for non-TS repos
docs/
└── constitutions/
├── current -> v3 # Symlink to current version
├── v1/ # First constitution
├── v2/ # Second constitution
└── v3/ # Current constitution (selective abstraction)
├── meta.md
├── architecture.md
├── patterns.md
├── schema-rules.md
├── tech-stack.md
└── testing.md
commitment is intended to be published to npm. Before publishing:
# Clean and build
bun run clean
bun run build
# Verify everything works
./dist/cli.js --dry-run
# Publish (requires npm access)
npm publishThe prepublishOnly script automatically cleans and builds before publishing.
- Package manager is Bun (development and runtime)
- Build targets Node.js 18+ with ESM-only output
- Uses Bun's built-in bundler for fast builds (CLI-only package)
- Test runner is bun:test (Jest-compatible API)
- CLI file has relaxed linter rules (allows
console.logandprocess.exit) - Config files have relaxed rules (no default export restriction)
- Always use commitment itself for commits (dogfooding!)
When working on commitment:
- Create a new stacked branch with
gs bc <branch-name> - Make your changes following the code style guidelines
- Run
bun run lint:fixto ensure code quality - Use commitment itself to generate commit messages
- Continue with
gs bcfor the next ticket - Submit the stack with
gs stack submit
# Start working on issue #1
gs bc issue-1-add-timeout-option
# ... make changes ...
git add .
./dist/cli.js # Uses commitment to generate message
# Start working on issue #2 (stacked on #1)
gs bc issue-2-improve-error-handling
# ... make changes ...
git add .
./dist/cli.js
# Submit both PRs
gs stack submit
# If you need to update issue #1:
git checkout issue-1-add-timeout-option
# ... make more changes ...
git add .
git commit --amend --no-edit
gs stack restack # Rebases issue #2 on topThis workflow keeps commits clean, branches organized, and PRs reviewable in logical stacks.