This guide is for TypeScript developers who want to contribute to Minsky. It covers setup, testing, code patterns, and the development workflow.
For AI agent instructions, see CLAUDE.md.
- Bun (runtime and package manager) — install from https://bun.sh
- Git
- PostgreSQL with pgvector (optional) — only needed for embedding-related features
git clone https://github.com/edobry/minsky.git
cd minsky
bun install
bun link # makes the `minsky` CLI available globallyAfter bun link, you can run minsky directly from any directory.
The test suite uses Bun's built-in test runner. bunfig.toml configures
tests/setup.ts as a global preload, which mocks the logger and suppresses
console output during runs. No explicit --preload flag is needed.
# Unit tests (default — fast, no external deps)
bun test --timeout=15000 ./src ./tests/adapters ./tests/domain
# Shorthand via npm script
bun run test
# Watch mode (re-runs on file changes)
bun run test:watch
# Integration tests (requires database and external services)
bun run test:integration
# All tests including integration
bun run test:all
# Coverage report
bun run test:coverageSet DEBUG_TESTS=1 to disable console mocking and see full output:
DEBUG_TESTS=1 bun test src/domain/tasksMinsky uses constructor injection with fake implementations — not mock.module().
The ESLint rule custom/no-global-module-mocks enforces this: mock.module() is
banned in test files (the one exception is tests/setup.ts for the logger).
Define a plain object that satisfies the interface, pass it via the constructor:
import { describe, expect, test, beforeEach } from "bun:test";
import { TaskRoutingService } from "../../src/domain/tasks/task-routing-service";
import type { TaskGraphService } from "../../src/domain/tasks/task-graph-service";
import type { TaskServiceInterface } from "../../src/domain/tasks/taskService";
const mockTaskGraphService: TaskGraphService = {
listDependencies: async (taskId: string) => [],
listDependents: async () => [],
addDependency: async () => {},
removeDependency: async () => {},
getAllRelationships: async () => [],
getRelationshipsForTasks: async () => [],
} as unknown as TaskGraphService;
const mockTaskService: TaskServiceInterface = {
listTasks: async () => [],
getTask: async () => null,
} as unknown as TaskServiceInterface;
describe("TaskRoutingService", () => {
let service: TaskRoutingService;
beforeEach(() => {
// Inject fakes through the constructor — no module mocking needed
service = new TaskRoutingService(mockTaskGraphService, mockTaskService);
});
test("finds available tasks", async () => {
const result = await service.findAvailableTasks({ statusFilter: ["TODO"], limit: 10 });
expect(result).toEqual([]);
});
});Key principles:
- Fake objects implement only the methods the test exercises.
- Use
as unknown as InterfaceTypewhen the fake is intentionally partial. - Never use
mock.module()outside oftests/setup.ts. - Tests must not touch the real filesystem, real SQLite, or real git repos — the
custom/no-real-fs-in-testslint rule enforces this.
Minsky uses a shared command registry (src/adapters/shared/command-registry.ts).
A command defined once in the registry is automatically available in both the CLI
and the MCP server — no adapter duplication required.
- Define the command using
defineCommandwith a Zod parameter schema:
import { z } from "zod";
import { defineCommand, CommandCategory } from "../command-registry";
export const myCommand = defineCommand({
id: "tasks.my-command",
category: CommandCategory.TASKS,
name: "my-command",
description: "Does something useful",
parameters: {
taskId: {
schema: z.string(),
description: "The task ID to operate on",
required: true,
},
force: {
schema: z.boolean().default(false),
description: "Skip confirmation",
required: false,
},
},
execute: async (params, ctx) => {
// params.taskId and params.force are fully typed
return { result: "done" };
},
});-
Register it in the appropriate commands file under
src/adapters/shared/commands/. Browse that directory for examples of existing command groups. -
No CLI or MCP adapter changes needed — the registry wires both automatically.
See src/adapters/shared/ for the full set of existing commands organized by domain.
- TypeScript strict mode — no implicit
any, strict null checks - Double quotes for strings
- 2-space indentation
- 100-character line width
- ES5 trailing commas (objects, arrays, function parameters)
- LF line endings
- Template literals preferred over string concatenation (
prefer-templateis an error) - No
var— useconstorlet
Run the formatter and linter:
bun run format:check # check formatting (Prettier)
bun run format:all # format and auto-fix lint issues
bun run lint # ESLint only
bun run typecheck # @typescript/native-preview --noEmit (root tsconfig + src/cockpit/web/tsconfig.json)
bun run validate-all # run all checks (format + lint + typecheck + tests)The project enforces 12 custom rules under the custom/ prefix:
| Rule | Level | Purpose |
|---|---|---|
no-underscore-prefix-mismatch |
error | Underscore prefix must match actual usage |
no-jest-patterns |
error | Prevents Jest API usage (project uses Bun test) |
no-real-fs-in-tests |
warn | Tests must not touch the real filesystem |
no-global-module-mocks |
error | mock.module() banned outside tests/setup.ts |
no-unreliable-factory-mocks |
warn | Prevents async factory race conditions |
no-cli-execution-in-tests |
warn | Flags architectural violations in tests |
no-magic-string-duplication |
warn | Encourages extracting repeated string literals |
no-unwaited-async-factory |
error | Prevents unwaited async factory calls |
no-singleton-reach-in |
warn | Keeps singleton resolution in composition roots |
no-tests-directories |
warn | Encourages co-located test files |
no-unsafe-git-exec |
error | All git operations must have timeout protection |
no-excessive-as-unknown |
warn | Discourages overuse of as unknown casts |
Minsky rules are authored in .minsky/rules/ and compiled to harness-specific outputs
(.cursor/rules/, AGENTS.md, CLAUDE.md). The split matters:
.minsky/rules/— canonical source. Edit rule files here.minsky rules createlands new rules here by default..cursor/rules/— compile output, regenerated from.minsky/rules/bybun run minsky rules compile --target cursor-rules. Do not hand-edit — changes will be overwritten on the next compile.AGENTS.md,CLAUDE.md— monolithic compile outputs; same "don't hand-edit" rule applies. Both are in.prettierignoreso Prettier doesn't fight the compiler.
The pre-commit hook step 9 (see below) enforces that the committed .cursor/rules/ and
AGENTS.md match what the compiler would produce from .minsky/rules/. If you edit
a rule, re-run minsky rules compile and commit both locations together.
Migrating an existing project from .cursor/rules/-as-source to .minsky/rules/-as-source:
bun run minsky rules migrate # copies .cursor/rules/*.mdc → .minsky/rules/*.mdc
bun run minsky rules compile --target cursor-rules # regenerate .cursor/rules/
git add .minsky/rules/ .cursor/rules/After migration, .minsky/rules/ is authoritative. minsky rules migrate --dry-run
shows what would change without writing, and --force overwrites any existing files
in the destination.
Skills, agents, and rules can all be authored as TypeScript modules using the
@minsky/definitions factories, then compiled to the harness-specific formats Claude
Code and Cursor expect. The pipeline ships three targets (mt#913) — pick whichever
matches the kind of artifact you are authoring:
| Target | Source | Output | Format |
|---|---|---|---|
claude-skills |
.minsky/skills/<name>/skill.ts |
.claude/skills/<name>/SKILL.md |
Agent Skills YAML + body |
claude-agents |
.minsky/agents/<name>/agent.ts |
.claude/agents/<name>.md |
Claude Code agent format |
cursor-rules-ts |
.minsky/rules/<name>/rule.ts |
.cursor/rules/<name>.mdc |
Cursor .mdc rule format |
Run one target at a time:
bun run minsky compile --target claude-skills # compile all skills
bun run minsky compile --target claude-agents # compile all agents
bun run minsky compile --target cursor-rules-ts # compile all TS-authored rules
bun run minsky compile --target <id> --check # exit non-zero if output is stale
bun run minsky compile --target <id> --dry-run # print what would changeFor each target, the source directory name must equal the name declared in the
TypeScript definition. For example, .minsky/agents/implementer/agent.ts must set
agent.name: "implementer". Definitions that violate this invariant are silently
skipped during compile (pushed to definitionsSkipped in the result).
Why: listOutputFiles (which powers --check) only sees directory names — it cannot
load every definition to discover the declared name. Enforcing equality keeps the
check and compile paths in lockstep without an expensive load-all step.
The older bun run minsky rules compile --target cursor-rules reads flat
.minsky/rules/*.mdc files and writes to the same .cursor/rules/ directory. It
predates the TS-first pipeline and still runs today, including in the pre-commit hook.
Both pipelines can operate safely on .cursor/rules/: the legacy --check filters
out TS-owned rule IDs (those with a .minsky/rules/<name>/rule.ts source) before
comparing content or doing orphan detection. Until all rules migrate to TypeScript
sources (tracked separately), the two pipelines coexist.
.claude/skills/, .claude/agents/, and .cursor/rules/ all host hand-authored
artifacts alongside compile output. Targets declare sharedOutputDirectory: true so
--check skips orphan detection — a hand-authored SKILL.md next to a compiled one is
not a stale file.
See the preceding "Rules Storage Model" section for the one-time migration from
.cursor/rules/-as-source to .minsky/rules/*.mdc-as-source (mt#588 / mt#1090).
Migrating further from .mdc sources to rule.ts sources is a separate follow-on and
not automated yet.
Husky runs a TypeScript pre-commit hook (src/hooks/pre-commit.ts) that enforces
quality gates in order from fastest to slowest:
- Code formatting — Prettier via lint-staged (staged files only, ~1s)
- Completion-manifest regeneration — runs
bun run build:completion-manifestand re-stagessrc/generated/completion-manifest.jsonif it changed (~1s). Unlike the compile-staleness checks below, this step auto-fixes and re-stages instead of blocking — the manifest is a mechanically derived structural artifact with no editorial content, so there is nothing for a contributor to manually remediate. - Variable naming check — underscore prefix mismatch detection (~1s)
- TypeScript type checking —
tsgo --noEmit(~1.5s) - ESLint — full lint with zero-error gate (~5–10s); console-usage rules
(
custom/no-raw-console) are enforced here, not as a standalone pass - Secret scanning — gitleaks (~2–3s)
- Unit tests — full test suite (~15–30s)
- ESLint rule tests — tests for the custom lint rules
- Rules compile staleness check — runs
minsky rules compile --checkfor each opted-in target (AGENTS.md, CLAUDE.md,.cursor/rules/). If a compiled output is out of date relative to the source rules, the commit is blocked with a message naming the stale file. Remediation: runbun run minsky rules compile --target <target>and re-stage the regenerated output.
The commit-msg hook validates commit message format (Conventional Commits style:
type(scope): description). It runs automatically via .husky/commit-msg on every
commit. To validate a message manually without committing, run it directly against a
message file: bun src/hooks/commit-msg.ts <path-to-message-file> — e.g.
bun src/hooks/commit-msg.ts .git/COMMIT_EDITMSG to re-check the most recent commit
message.
The pre-push hook runs the unit test suite again before pushing to the remote.
If a hook fails, fix the reported issue and re-stage — the commit has not been created yet, so there is no need to amend.
Minsky uses sessions for all development work. A session is an isolated git clone on a dedicated branch.
# Create a task (optional — for tracked work)
minsky tasks create "My feature" --description "..."
# Start a session (creates a branch and working directory)
minsky session start --task <task-id>
# or for untracked work:
minsky session start --name my-feature
# The session directory is printed — work there
cd ~/.local/state/minsky/sessions/<session-id>/
# Edit files, run tests...
bun run test
bun run validate-all
# Commit (use the minsky CLI, not bare git)
minsky session commit -m "feat: add my feature"
# Open a PR
minsky session pr create --title "feat: my feature" --type featSession directories are at ~/.local/state/minsky/sessions/<uuid>/. Each session
tracks its own branch (task/<backend>-<id> format).
After a PR is merged, delete the session and start a fresh one for any follow-up work — sessions are frozen after merge.
Minsky can run as an MCP (Model Context Protocol) server, exposing all commands as MCP tools:
bun run src/cli.ts mcp startYou can inspect the MCP server using the official MCP inspector:
npx @modelcontextprotocol/inspector bun run src/cli.ts mcp startSee docs/architecture.md for a full description of the layered architecture (Domain → Adapters → Infrastructure), the command registry pattern, and the DI container design.
There is no CHANGELOG.md in this repo. The project's record stack lives in
the following surfaces, each with a different scope:
- Tasks (Minsky DB) — every change is scoped to a task, with a spec, success
criteria, acceptance tests, and status history. Browse via
mcp__minsky__tasks_list/mcp__minsky__tasks_getor the CLI equivalent. - Pull requests (GitHub) — per-change reviewer audit and merge events, authoritative for what code shipped when.
- Memories (
~/.claude/projects/.../memory/) — durable cross-conversation feedback and context for AI agents working in this repo. - Notion — strategic memos, position papers, and incident logs (e.g., the 2026-04-26 mitigation-tier incident memo).
A user-facing release notes surface (e.g., GitHub Releases) is a separate concern; if one is added later it should be sourced from PR titles + task metadata, not from a hand-maintained changelog.