8dd82d99da
Senses trigger shell commands only. Workflows are invoked via CLI.
SenseTrigger is now { command: string } — no discriminated union.
Closes #318
Co-authored-by: Cursor <cursoragent@cursor.com>
227 lines
7.0 KiB
Markdown
227 lines
7.0 KiB
Markdown
# 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.
|
|
|
|
|
|
|
|
### Sense State Persistence
|
|
|
|
Each sense's state is persisted as a JSON file at `data/senses/<name>.json` (relative to the nerve root, typically `~/.uncaged-nerve/`).
|
|
|
|
| Event | Behavior |
|
|
|-------|----------|
|
|
| **Worker start** | Read `state.json`; if missing or corrupt, use `initialState` from the sense module |
|
|
| **Compute success** | Write new state atomically (write-temp + rename), then update in-memory state |
|
|
| **Compute failure** | State unchanged (both disk and memory) |
|
|
| **Daemon restart** | State restored from last successful write |
|
|
|
|
State files are written atomically (temp file + rename) to prevent corruption on crash.
|
|
|
|
## Language & Paradigm
|
|
|
|
### Functional-first
|
|
|
|
Use `function` + `type`, not `class` + `interface`.
|
|
|
|
```typescript
|
|
// ✅ 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`.
|
|
|
|
```typescript
|
|
// ✅ 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:
|
|
|
|
```typescript
|
|
import type { SenseTrigger } from "@uncaged/nerve-core";
|
|
|
|
// ✅ Good — sense modules return explicit next state + optional shell trigger only
|
|
type SenseComputeReturn<S> = {
|
|
state: S;
|
|
trigger: SenseTrigger | 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.
|
|
|
|
```typescript
|
|
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 `Result` type for expected failures
|
|
- `throw` only for unrecoverable bugs (programmer errors)
|
|
- No try-catch for flow control
|
|
|
|
```typescript
|
|
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):
|
|
1. `sense-runtime.ts` — user module paths known only at runtime
|
|
2. `workflow-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
|
|
|
|
```bash
|
|
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
|
|
```
|
|
|
|
- `core` is the shared layer; `cli` and `daemon` both depend on it
|
|
- `cli` and `daemon` must NOT depend on each other
|
|
|
|
## Commit Convention
|
|
|
|
```
|
|
<type>(<scope>): <description>
|
|
|
|
type: feat | fix | refactor | docs | chore | test
|
|
scope: core | cli | daemon | rfc-001 | ...
|
|
```
|