Phase 4 of RFC #308: Stateful Sense refactor. - CLAUDE.md: updated diagram, tables, examples (no more Signal) - Cleaned stale Signal Bus / DrizzleDB / _signals / retention refs across READMEs, .cursor rules, copilot instructions, .knowledge - Removed drizzle-orm from core package.json (no longer used) - Updated pnpm-lock.yaml Refs #308
6.3 KiB
Nerve Coding Conventions
Core Concepts
External World → Sense(state) → { newState, workflow? } → Workflow → Log
↑ ↑
"what to observe" "what to do"
Nerve is a lightweight observation engine daemon for autonomous agents. It continuously observes external state, reacts to changes via declarative rules, and orchestrates multi-step workflows.
Key Terms
| Concept | What it is |
|---|---|
| Sense | A stateful compute(state) function. Returns new state and an optional workflow trigger. State persisted as JSON. Scheduling configured in nerve.yaml. |
| Workflow | A stateful multi-step execution. Contains Roles (actors with side effects) and a Moderator (pure router). Each instance is a Thread with a unique runId. |
| Log | Immutable audit trail. Records executions, state transitions, errors. Cannot trigger Senses — prevents feedback loops. |
| Engine | The kernel orchestrating everything. Holds Process Manager, Workflow Manager, Sense Scheduler. Never loads user code directly — all user code runs in isolated Workers. |
| Daemon | The nerve-daemon package — engine runtime. Runs as a background process. |
Architecture Rules
- Two extension points: Sense (what to observe + when), Workflow (what to do)
- Process isolation: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other.
- Causality is one-directional: External world → Sense(state) → Workflow (when triggered) + Log. Logs are the end of the chain.
Language & Paradigm
Functional-first
Use function + type, not class + interface.
// ✅ Good
type WorkflowLaunch = {
senseName: string;
workflowName: string;
ts: number;
};
function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch {
return { senseName, workflowName, ts: Date.now() };
}
// ❌ Bad — no class, no interface
class WorkflowLaunch implements IWorkflowLaunch { ... }
Rules
| Rule | Description |
|---|---|
type over interface |
All type definitions use type |
function over class |
Pure functions + closures, no class |
No this |
Functions must not depend on this context |
| No inheritance | No extends, implements, abstract |
| Composition over inheritance | Use function composition |
| Immutability first | Use Readonly<T>, as const, avoid mutation |
| No optional properties | Use T | null instead of ?: — see below |
Exceptions
Classes are allowed when:
- Required by a third-party library (e.g. Drizzle's
sqliteTable) - Error subclasses (
class NerveError extends Error)
No Optional Properties
Never use ?:. All nullable fields must be explicit T | null.
// ✅ Good
type SenseConfig = {
group: string;
throttle: string | null;
timeout: string | null;
};
// ❌ Bad
type SenseConfig = {
group: string;
throttle?: string;
timeout?: string;
};
For mutually exclusive fields, use discriminated unions:
// ✅ Good — sense modules return explicit next state + optional workflow trigger
type SenseComputeReturn<S> = {
state: S;
workflow: WorkflowTrigger | null;
};
Workflow Naming
Workflow identifiers — WorkflowDefinition.name, the directory under workflows/, and keys under workflows: in nerve.yaml — must use verb-first kebab-case phrases so the name reads as an action.
- ✅
solve-issue,extract-knowledge,develop-sense - ❌
knowledge-extraction,issue-solver
Workflow authoring (user modules)
Roles and moderators take ThreadContext (threadId, start, steps) — not separate StartStep / message arrays.
import type { RoleResult, ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
type MyMeta = { round: number };
async function planner(ctx: ThreadContext): Promise<RoleResult<MyMeta>> {
void ctx.start;
void ctx.steps;
return { content: "plan", meta: { round: ctx.steps.length } };
}
const workflow: WorkflowDefinition<Record<"planner", MyMeta>> = {
name: "example",
roles: { planner },
moderator(ctx: ThreadContext<Record<"planner", MyMeta>>) {
return ctx.steps.length === 0 ? "planner" : END;
},
};
Modules & Exports
- Always named exports, never default exports
- One module = one responsibility, filename = purpose
Naming
| Type | Style | Example |
|---|---|---|
| Files | kebab-case | sense-scheduler.ts |
| Types | PascalCase | SenseScheduler |
| Functions/variables | camelCase | createSenseScheduler |
| Constants | UPPER_SNAKE | MAX_RETRY_COUNT |
| Generics | Single letter or descriptive | T, TValue |
Error Handling
- Use
Resulttype for expected failures throwonly for unrecoverable bugs (programmer errors)- No try-catch for flow control
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
Async
- Always
async/await, never.then()chains
No Dynamic Import
Do NOT use await import() in production code. Always use static top-level import.
Exceptions (must include a comment):
sense-runtime.ts— user module paths known only at runtimeworkflow-worker.ts— user module paths known only at runtime
Test files (__tests__/**) are exempt.
Toolchain
| Tool | Purpose |
|---|---|
| pnpm | Package manager |
| TypeScript | Type checking (strict mode) |
| Biome | Lint + format (replaces ESLint + Prettier) |
| tsup | Bundling |
Commands
pnpm run check # biome check (lint + format)
pnpm run format # biome format --write
pnpm run build # full build
pnpm test # run tests
Monorepo Structure
nerve/
packages/
core/ # @nerve/core — shared types and utils
cli/ # @nerve/cli — CLI entry point
daemon/ # @nerve/daemon — engine runtime
docs/ # RFCs, conventions
coreis the shared layer;clianddaemonboth depend on itclianddaemonmust NOT depend on each other
Commit Convention
<type>(<scope>): <description>
type: feat | fix | refactor | docs | chore | test
scope: core | cli | daemon | rfc-001 | ...