Development Guide
git clone https://github.com/protoLabsAI/protoWorkstacean.gitcd protoWorkstaceanbun installRequires Bun >= 1.1. There is no Node.js build step — Bun runs TypeScript directly.
Running the server locally
Section titled “Running the server locally”cp .env.dist .env # then fill in at minimum ANTHROPIC_API_KEY and WORKSTACEAN_API_KEYbun run src/index.tsUse --watch during development for automatic restarts on file change:
bun run --watch src/index.tsRunning tests
Section titled “Running tests”bun testTests are co-located with source. Test files follow the pattern <name>.test.ts or live in a __tests__/ directory alongside the module they test.
# Run a single test filebun test src/executor/__tests__/executor-registry.test.ts
# Run tests matching a patternbun test --test-name-pattern "resolution order"There is no separate test runner configuration file — bun test discovers all *.test.ts files automatically.
Test structure
Section titled “Test structure”Tests use bun:test — the same API as Jest/Vitest:
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";Unit tests — co-located with source, mock all bus dependencies:
import { describe, test, expect, mock } from "bun:test";import { ExecutorRegistry } from "../executor-registry.ts";import type { IExecutor, SkillRequest, SkillResult } from "../types.ts";
function makeExecutor(type: string): IExecutor { return { type, execute: mock(async (req: SkillRequest): Promise<SkillResult> => ({ text: `result from ${type}`, isError: false, correlationId: req.correlationId, })), };}
describe("ExecutorRegistry", () => { test("resolves registered skill", () => { const registry = new ExecutorRegistry(); const exec = makeExecutor("proto-sdk"); registry.register("daily_standup", exec); expect(registry.resolve("daily_standup")).toBe(exec); });});Integration tests — in test/integration/, use a real InMemoryEventBus:
import { describe, test, expect } from "bun:test";import { InMemoryEventBus } from "../../lib/bus.ts";import { PlannerPluginL0 } from "../../src/plugins/planner-plugin-l0.ts";
describe("GOAP loop integration", () => { test("planner dispatches action when preconditions match", async () => { const bus = new InMemoryEventBus(); // install plugins, publish world state, assert action dispatched });});Writing a plugin
Section titled “Writing a plugin”Create src/plugins/my-plugin.ts and implement the Plugin interface:
import type { Plugin, EventBus, BusMessage } from "../../lib/types.ts";
export class MyPlugin implements Plugin { readonly name = "my-plugin"; readonly description = "Short description"; readonly capabilities = ["my-capability"];
private bus?: EventBus; private readonly subscriptionIds: string[] = [];
install(bus: EventBus): void { this.bus = bus; const id = bus.subscribe("some.topic.#", this.name, (msg: BusMessage) => { void this._handle(msg); }); this.subscriptionIds.push(id); }
uninstall(): void { if (this.bus) { for (const id of this.subscriptionIds) { this.bus.unsubscribe(id); } } this.subscriptionIds.length = 0; this.bus = undefined; }
private async _handle(msg: BusMessage): Promise<void> { // ... handle the message }}Wire it into src/index.ts in the pluginRegistry array:
{ name: "my-plugin", condition: () => true, // or () => !!process.env.MY_ENV_VAR factory: async () => { const { MyPlugin } = await import("./plugins/my-plugin.js"); return new MyPlugin(); },},Writing an executor
Section titled “Writing an executor”Implement IExecutor in src/executor/executors/:
import type { IExecutor, SkillRequest, SkillResult } from "../types.ts";
export class MyExecutor implements IExecutor { readonly type = "my-executor";
async execute(req: SkillRequest): Promise<SkillResult> { try { const result = await doSomethingWith(req.content ?? req.skill); return { text: result, isError: false, correlationId: req.correlationId }; } catch (err) { return { text: "", isError: true, correlationId: req.correlationId, data: { error: String(err) }, }; } }}Register it in a plugin’s install() — do not subscribe to agent.skill.request directly:
install(_bus: EventBus): void { this.registry.register("my_skill", new MyExecutor(), { priority: 5 });}File structure
Section titled “File structure”src/ index.ts # Bootstrap and plugin wiring executor/ types.ts # IExecutor, SkillRequest, SkillResult, ExecutorRegistration executor-registry.ts # ExecutorRegistry skill-dispatcher-plugin.ts # Sole agent.skill.request subscriber executors/ a2a-executor.ts function-executor.ts proto-sdk-executor.ts workflow-executor.ts __tests__/ plugins/ CeremonyPlugin.ts goal_evaluator_plugin.ts planner-plugin-l0.ts action-dispatcher-plugin.ts skill-broker-plugin.ts ... agent-runtime/ agent-runtime-plugin.ts # Registrar for in-process agents agent-executor.ts agent-definition-loader.ts types.ts tool-registry.ts router/ router-plugin.ts skill-resolver.ts project-enricher.ts world/ domain-discovery.ts event-bus/ topics.ts action-events.tslib/ types.ts # BusMessage, Plugin, EventBus (shared) bus.ts # InMemoryEventBus plugins/ world-state-engine.ts discord.ts github.ts ...workspace/ goals.yaml actions.yaml agents.yaml agents/ ceremonies/ domains.yaml projects.yamltest/ integration/tests/ (schema and submission tests)Type checking
Section titled “Type checking”bunx tsc --noEmitThe project uses TypeScript strict mode. All exported types should have JSDoc comments on non-obvious fields.
Conventions
Section titled “Conventions”- Named exports only in
.tsfiles. Exception:_meta.tsfiles use a default export for Nextra. - No inter-plugin references — plugins communicate through the bus.
- Async handlers —
bus.subscribecallbacks should bevoidfunctions that internally handle errors with try/catch or.catch(). Do not let unhandled promise rejections propagate. - correlationId is sacred — never generate a new
correlationIdmid-flow. Always propagate the one from the triggering message. - Imports use
.tsextensions (Bun resolves them correctly; avoid.jsaliases in source files).