| name | package-authoring |
| description | Guide for creating new workspace packages in the skunkworks template. Use when building a new reusable library, extracting shared logic from an executor, creating a venue interface, building a testing utility, or any time you're about to put reusable code in packages/. Also use when the user says 'create a package', 'extract this into a package', 'build a library for X', or when you notice executor code that should be shared. Encourages high test coverage, clean API surfaces, and the skunk- naming convention for future TradeCore promotion. |
Package Authoring
You are creating a new workspace package in the skunkworks monorepo. Packages here are reusable libraries that serve multiple executors. They follow a strict convention so they can be promoted to TradeCore later.
Before creating a package
-
Check TradeCore first. Load /tradecore-packages and verify no published @recallnet/* package already owns this concept. If one exists and is close enough, use it. If the gap is small, file a TradeCore Goal issue instead of building locally.
-
Is this really reusable? If only one executor needs it, keep it in the executor. Extract to a package when a second executor would benefit, or when the logic is complex enough to deserve its own test surface.
-
Check for circular dependencies. If your new package depends on other workspace packages, verify you're not creating a cycle. Load /senior-architect and run the dependency analyzer if the dependency graph is getting complex.
-
Name it. All workspace packages use the convention @recallnet/skunk-{name}. The skunk- prefix means "local to skunkworks, not yet promoted to TradeCore." When a package proves its value and gets promoted, the skunk- prefix is removed and it becomes a real @recallnet/{name} published package.
Creating the package
Step 1: Scaffold
mkdir -p packages/{name}/src
Every package needs these files:
packages/{name}/
├── src/
│ ├── index.ts # Barrel exports — the public API
│ └── {module}.ts # Implementation modules
├── package.json
├── tsconfig.json
├── tsup.config.ts
├── eslint.config.mjs
└── src/{module}.test.ts # Tests beside the source
Step 2: package.json
{
"name": "@recallnet/skunk-{name}",
"version": "0.1.0",
"type": "module",
"description": "One-line description of what this package does",
"exports": {
".": {
"development": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsup",
"lint": "eslint --quiet --cache --cache-location node_modules/.cache/eslint/ --cache-strategy content src",
"check-types": "tsc --noEmit",
"test": "vitest run --silent",
"format": "prettier --write --ignore-path ../../.prettierignore .",
"format:check": "prettier --check --ignore-path ../../.prettierignore ."
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.9.0",
"vitest": "^2.1.0"
}
}
Add runtime dependencies sparingly. Zero external deps is ideal. If you need another workspace package, use "@recallnet/skunk-{dep}": "workspace:*".
Step 3: TypeScript config
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
Non-negotiable: strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes. These catch bugs before they reach executors.
Step 4: tsup config
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
target: "node20",
outDir: "dist",
clean: true,
dts: true,
});
Step 5: ESLint config
Copy from an existing package (e.g., packages/skunk-venue-budget/eslint.config.mjs). The base config enforces no-console, jsdoc/require-jsdoc, no-any, and the recall/no-inline-error-strings rule.
Step 6: Wire into the monorepo
pnpm install
The workspace is already configured to pick up packages/*. After install, verify:
pnpm --filter @recallnet/skunk-{name} check-types
pnpm --filter @recallnet/skunk-{name} test
pnpm --filter @recallnet/skunk-{name} build
API design principles
Export only what consumers need
The index.ts barrel is your public API. Everything not exported is private. Be deliberate:
export { VenueBudget, type VenueBudgetOptions } from "./budget.js";
export * from "./internal-helpers.js";
Use generics for extensibility
Design for the general case, let executors specialize:
export class ExecutionLedger<EntryType extends string = BuiltinEntryType> { ... }
export class KalshiLedger { ... }
Make invalid states unrepresentable
Use TypeScript's type system to prevent misuse:
type TradeSide = "buy" | "sell";
interface Trade { side: string; }
Fail loudly
Throw on invalid input. Never return silently with a default:
if (amountUsd < 0) {
throw new Error(`amount must be non-negative, got ${String(amountUsd)}`);
}
const safeAmount = Math.max(0, amountUsd);
No side effects in constructors
Constructors should initialize state. File I/O, network calls, and event listeners go in explicit methods:
const ledger = new ExecutionLedger();
ledger.loadFromFile(path);
const ledger = new ExecutionLedger(path);
Note: the existing ExecutionLedger loads from file in the constructor for convenience — this is acceptable for append-only JSONL replay but not a pattern to generalize.
Testing requirements
Coverage floors
Packages are promotion material — they need to prove their correctness through comprehensive testing.
| Code category | Branch coverage minimum |
|---|
| Money-touching (ledger, budget, OMS, fees, sizing) | 100% |
| Core logic (replay, position tracking, scoring) | 95% |
| Utilities (hashing, serialization, path resolution) | 90% |
| All packages, minimum bar | 90% |
If your package touches money, there is no excuse for untested branches. Every if/else, every error path, every edge case gets a test.
Test patterns
Write tests beside the source:
src/
├── budget.ts
├── budget.test.ts ← right here
├── fees.ts
└── fees.test.ts
Use descriptive test names that read as specifications:
describe("VenueBudget", () => {
describe("tryReserve", () => {
it("rejects when cumulative reservations exceed cap", () => { ... });
it("always succeeds for uncapped venues", () => { ... });
it("fails for zero-capped venue with any positive amount", () => { ... });
});
});
Test edge cases explicitly:
- Zero values (0 amount, 0 contracts, 0 trades)
- Negative values (should throw)
- Floating point precision (0.1 + 0.2 ≠ 0.3)
- Empty collections (empty arrays, empty maps, no entries)
- Boundary conditions (exactly at cap, one over, one under)
Extract repeated test values into constants:
const TEST_TIMESTAMP = "2026-04-15T00:00:00Z";
const TEST_VENUE = "test-venue";
The ESLint config enforces sonarjs/no-duplicate-string — repeated string literals in tests will fail lint.
Property-based testing for invariants
For packages with mathematical properties (fee calculations, position tracking, capital accounting), consider property-based tests:
it("reserve then release is identity", () => {
const budget = new VenueBudget({ caps: { a: 100 } });
budget.tryReserve("a", 42);
budget.release("a", 42);
expect(budget.remaining("a")).toBe(100);
});
Lint compliance
The repo's ESLint config is strict. Common things that trip up new packages:
| Rule | What it means | Fix |
|---|
jsdoc/require-jsdoc | Classes and constructors need JSDoc | Add /** description */ |
jsdoc/require-description | JSDoc must have body text, not just @param | Add a description line |
recall/no-inline-error-strings | new Error("plain string") is banned | Use template literals: new Error(`msg ${String(val)}`) |
recall/no-local-zod-schemas | Zod schemas should come from centralized packages | Disable in the package's eslint config if this IS the schema package |
sonarjs/no-duplicate-string | Repeated strings in tests | Extract to constants |
unicorn/prevent-abbreviations | No e, prev, tmp | Use entry, previous, temporary |
@typescript-eslint/no-confusing-void-expression | Void in arrow shorthand | Wrap with braces: () => { doThing(); } |
The promotion path
A skunkworks package gets promoted to TradeCore when:
- It's used by 2+ executors — proves the abstraction is actually reusable
- 90%+ branch coverage — proves correctness through testing, not hope
- Clean API surface — generic types, no executor-specific assumptions baked in
- Zero
any — the type system should guide consumers
- The gap is filed — a TradeCore Goal issue exists describing what the package does and why it belongs in the shared platform
Promotion process:
- File a Goal issue on
recallnet/tradecore via /issue-filing
- The TradeCore team creates the real
@recallnet/{name} package
- Skunkworks executors migrate from
@recallnet/skunk-{name} to @recallnet/{name}
- The local package is removed
Until promotion, the skunk- prefix keeps the boundary clear: this is local, experimental, and may change without notice.
Quick checklist for new packages