Compare commits

...

35 Commits

Author SHA1 Message Date
xiaomo 0d78df89b1 refactor(core): consolidate file structure — 22 files → 6 (closes #273) 2026-04-30 09:15:18 +00:00
xiaomo 0140cdd952 Merge pull request 'refactor: RFC-005 — Separate Agent and Role types' (#272) from refactor/rfc-005-phase-1 into main 2026-04-30 08:29:12 +00:00
xiaomo bfadfffd40 fix: move isDryRun to value export (not type-only) 2026-04-30 08:27:07 +00:00
xiaomo e6093c35db docs: update knowledge cards for RFC-005 (ThreadContext, AgentFn) 2026-04-30 08:09:05 +00:00
xiaomo de8c7c5150 fix: address review — revert unrelated sense-worker change, restore isDryRun as deprecated 2026-04-30 08:00:46 +00:00
xiaomo f799cee51f refactor(cli,docs): RFC-005 Phase 4 — update templates, tests, docs (closes #271) 2026-04-30 07:24:11 +00:00
xiaomo d13b59e787 refactor(daemon): RFC-005 Phase 3 — workflow-worker uses ThreadContext (closes #270) 2026-04-30 07:10:58 +00:00
xiaomo 975f15c66d refactor(workflow-utils): RFC-005 Phase 2 — adapt to ThreadContext, new AgentFn signature (closes #269) 2026-04-30 06:59:15 +00:00
xiaomo 3e51335d91 refactor(core): RFC-005 Phase 1 — ThreadContext, AgentFn, Role signature (closes #268) 2026-04-30 06:54:03 +00:00
xiaoju 9c832b0e21 docs(knowledge): update cards via knowledge-extraction workflow (5q/round)
7 cards updated, 4 new cards added. Topics: signal-routing,
worker-isolation, storage-layer, adapter-isolation, sense contracts,
workflow runtime enforcement, coding conventions details.

小橘 <xiaoju@shazhou.work>
2026-04-30 05:56:29 +00:00
xiaoju 2387b73141 fix(daemon): remove stale exports openPeerDb, loadComputeFn from index
These functions were renamed/removed from sense-runtime.ts but index.ts
still re-exported them, causing rslib build to fail (no JS output).

小橘 <xiaoju@shazhou.work>
2026-04-30 05:56:24 +00:00
xiaoju 8824421f26 docs: remove Reflex concept from architecture docs and CLAUDE.md
Reflex was folded into Sense config (interval/on) and ComputeResult
(workflow trigger). Two extension points now: Sense + Workflow.

— 小橘 🍊(NEKO Team)
2026-04-30 00:43:04 +00:00
xiaoju b27a6aced8 feat: sense compute returns ComputeResult<T> with workflow trigger support
- SenseComputeFn returns ComputeResult<T> = null | { signal, workflow }
- sense-runtime persists result.signal, not result itself
- sense-worker sends workflow trigger message when workflow is non-null
- New SenseWorkflowTriggerMessage in IPC protocol
- Knowledge card updated to match

— 小橘 🍊(NEKO Team)
2026-04-30 00:37:16 +00:00
xiaoju bfd8fe729a docs: update sense knowledge card to match pure compute API
- No args (no db, no peers, no signal)
- Runtime handles db.insert
- Export { compute, table }

— 小橘 🍊(NEKO Team)
2026-04-30 00:28:23 +00:00
xiaoju 748df10e6a fix: remove AbortSignal from SenseComputeFn
Compute is truly zero-arg now: () => Promise<T | null>.
Runtime handles timeout via Promise.race, sense doesn't need signal.

— 小橘 🍊(NEKO Team)
2026-04-30 00:22:57 +00:00
xiaoju 3ef9cfcb27 Merge pull request 'refactor: pure sense compute — no db, no peers' (#265) from refactor/pure-sense-compute into main 2026-04-30 00:11:57 +00:00
xiaoju 8c9adf08c5 refactor: pure sense compute — no db, no peers
SenseComputeFn is now (signal: AbortSignal) => Promise<T | null>.
sense-runtime handles db.insert when compute returns non-null.
Senses export { compute, table } — SenseModule type added to core.

Closes #264
Refs #260

— 小橘 🍊(NEKO Team)
2026-04-30 00:07:49 +00:00
scottwei 08e8020cb6 Merge pull request 'feat: add sense contract types to nerve-core' (#263) from feat/sense-contract into main
Reviewed-on: #263
2026-04-29 23:44:01 +00:00
xiaoju e287e07dab feat: add sense contract types to @uncaged/nerve-core
Export SenseComputeFn, SenseComputeOptions, SenseBlobStore,
SensePeerMap — formalizing the compute function signature that
senses must implement.

Closes #262
Refs #260

— 小橘 🍊(NEKO Team)
2026-04-29 15:11:49 +00:00
xiaomo 239dfffb28 Merge pull request 'feat: add @uncaged/nerve-workflow-meta package' (#259) from feat/workflow-meta-package into main 2026-04-29 14:48:57 +00:00
xiaoju 6ccb33bf40 feat: add @uncaged/nerve-workflow-meta package
Extract develop-sense and develop-workflow meta workflows into a
shared package. Reviewer and committer roles imported from their
respective packages.

Refs RFC-004 Phase 2

— 小橘 🍊(NEKO Team)
2026-04-29 14:47:12 +00:00
xiaomo 0c95a9d716 Merge pull request 'feat: add @uncaged/nerve-role-reviewer package' (#258) from feat/role-reviewer-package into main 2026-04-29 14:33:45 +00:00
xiaoju aa64ea86ca feat: add @uncaged/nerve-role-reviewer package
Extract shared reviewer role (diff analysis, convention checking) into
a reusable package. Identical logic currently duplicated across
develop-sense and develop-workflow workspaces.

Refs RFC-004 Phase 1
2026-04-29 14:31:36 +00:00
xiaomo 431627019a Merge pull request 'feat: add @uncaged/nerve-role-committer package (RFC-004 Phase 1)' (#257) from feat/rfc004-role-committer into main 2026-04-29 14:16:11 +00:00
xiaoju 915584ff11 feat: add @uncaged/nerve-role-committer package (RFC-004 Phase 1)
First shared role package. Extracts workspace committer into a
reusable package with decorator chain (withDryRun + onFail).

Also fixes workflow-utils test types (StartStep shape, TestMeta constraint).
2026-04-29 14:07:26 +00:00
xiaoju 3644cce2c8 Merge pull request 'docs: RFC-004 package architecture' (#256) from docs/rfc-004-package-architecture into main 2026-04-29 14:03:02 +00:00
xiaoju 9855eba894 docs: RFC-004 package architecture — shareable workflows, roles & senses 2026-04-29 13:29:53 +00:00
scottwei 554933d43b Merge pull request 'feat(workflow-utils): add withDryRun role wrapper' (#255) from feat/254-with-dry-run into main
Reviewed-on: #255
2026-04-29 13:28:22 +00:00
xiaoju 9c11cd53e6 rename with-dry-run → role-decorators, remove RFC-004 from this PR 2026-04-29 13:27:08 +00:00
xiaoju c08e7f085d refactor(workflow-utils): split withDryRun into decorator chain
- withDryRun(opts) — only handles dry-run skip
- onFail(opts) — only handles try/catch error wrapping
- decorateRole(role, [...]) — composes decorators left-to-right
- RoleDecorator<M> type for custom decorators

Addresses review feedback on #255
2026-04-29 13:23:40 +00:00
xiaoju 4a4a03a2bc feat(workflow-utils): add withDryRun role wrapper
Extracts repeated dry-run skip + try/catch error handling into a
reusable wrapper. Includes tests.

Closes #254
2026-04-29 12:57:33 +00:00
xiaoju bfb5b9b17d Merge pull request 'chore: add .knowledge/ curated cards + knowledge.yaml' (#251) from chore/knowledge-cards into main 2026-04-29 10:01:22 +00:00
xiaomo a566cdabf8 Merge pull request 'refactor: replace WorkflowSpec with createRole helper' (#253) from refactor/252-create-role into main 2026-04-29 09:55:58 +00:00
xiaoju 9b4ab6225a refactor(core): remove WorkflowSpec and compileWorkflowSpec
Add createRole to workflow-utils wrapping AgentFn, Zod meta, and extractMetaOrThrow.

Refs #252

Signed-off-by: 小橘 🍊(NEKO Team) <dev@uncaged.ai>
Made-with: Cursor
2026-04-29 09:54:13 +00:00
小橘 🍊(NEKO Team) 77500ee6dd feat(daemon): pass dryRun into compileWorkflowSpec extractFn
Adds optional CursorAdapter.mode for ask/plan. Export zodMeta from workflow-utils for RoleSpec meta schemas.

Made-with: Cursor
2026-04-29 09:35:12 +00:00
90 changed files with 3354 additions and 1399 deletions
+171
View File
@@ -0,0 +1,171 @@
# Adapter Process Isolation
Describes sandboxing, process isolation, resource limits, and timeout enforcement for adapter invocations in the Nerve workflow system.
## Process Isolation Model
Adapters run in a **two-tier isolation** model:
1. **Workflow Worker Process** — Each workflow runs in a dedicated Node.js worker process (`workflow-worker.ts`) forked from the main daemon
2. **Adapter Child Process** — Each adapter spawns CLI tools as child processes via `spawnSafe()` with `shell: false`
## Resource Limits & Timeouts
### Adapter-Level Timeouts
- **Default timeout**: 300 seconds (300,000ms) for both cursor and hermes adapters
- **Configurable** via `AgentConfig.timeout` in adapter factory functions
- **Wall-clock enforcement** using `setTimeout()` — kills child process with `SIGTERM` on timeout
- **AbortSignal support** — external cancellation triggers immediate `SIGTERM`
### Timeout Behavior
```ts
// Timeout resolution priority (packages/core/src/spawn-safe.ts):
// 1. Explicit timeoutMs value
// 2. AbortSignal presence → no internal timer (relies on external abort)
// 3. DEFAULT_TIMEOUT_MS (300_000) fallback
```
- Child process terminated with `SIGTERM` on timeout/abort
- Returns `{ kind: "timeout", stdout, stderr }` error result
- **No grace period** — immediate kill
- **No SIGKILL escalation** — relies entirely on `SIGTERM` effectiveness
#### SIGTERM Limitations
If a child process **ignores or blocks `SIGTERM`** (e.g., signal handlers, blocked delivery):
- **No fallback to `SIGKILL`** — process may remain alive indefinitely
- **No escalation timer** — spawnSafe() does not implement progressive signal escalation
- **Potential zombie/orphan risk** — unresponsive processes continue consuming resources
- **OS-level cleanup only** — relies on parent process death or OS reaping mechanisms
## Sandboxing Characteristics
### What's Isolated
- **File system**: Child process runs in specified `cwd` (workflow working directory)
- **Environment**: Controlled env vars via `nerveCommandEnv()` + optional overrides
- **Network**: No explicit restrictions (inherits parent process network access)
- **Process tree**: Child processes are direct children, not containerized
### What's NOT Sandboxed
- **No resource quotas** (CPU, memory, disk I/O limits)
- **No filesystem chroot/containers** — full filesystem access within user permissions
- **No network isolation** — can make arbitrary network calls
- **No syscall filtering** — no seccomp or similar restrictions
#### Runtime Resource Enforcement
**No active resource monitoring or constraints**:
- **No cgroups** (Linux) — no CPU, memory, or I/O limits enforced
- **No job objects** (Windows) — no resource quotas or process tree limits
- **No worker_threads resource tracking** — Node.js worker processes run unrestricted
- **Pure timeout-based enforcement** — only wall-clock time limits via `setTimeout()`
- **OS-scheduled resource sharing** — relies entirely on operating system process scheduling
Adapters can consume unlimited:
- **CPU time** (until timeout)
- **Memory** (until OOM)
- **Disk I/O** (no quotas)
- **Network bandwidth** (no throttling)
- **File descriptors** (until ulimit)
#### Environment Variable Security
The `nerveCommandEnv()` function provides **minimal sanitization**:
```ts
// spawn-safe.ts lines 47-55
export function nerveCommandEnv(): SpawnEnv {
const home = homedir();
const pnpmHome = join(home, ".local/share/pnpm");
return {
...process.env, // ← Full parent environment inherited
PNPM_HOME: pnpmHome,
PATH: `${pnpmHome}:${process.env.PATH ?? ""}`,
};
}
```
- **No filtering of sensitive keys** — `NODE_OPTIONS`, `LD_PRELOAD`, `PYTHONPATH` passed through unchanged
- **Full environment inheritance** — all parent process environment variables copied
- **Injection risk** — malicious env vars (e.g., `NODE_OPTIONS=--require=evil.js`) affect Node.js child processes
- **Path manipulation** — sensitive PATH entries remain accessible to adapters
## Security Model
### Execution Context
- Uses `shell: false` to prevent shell injection attacks
- Arguments passed as separate array elements (not shell-parsed)
- PATH includes `~/.local/share/pnpm` for tool discovery
- Inherits parent process user/group permissions
#### File Descriptor Management
```ts
// spawn-safe.ts line 122
stdio: ["ignore", "pipe", "pipe"]
```
- **stdin closed**: Child receives no input (`stdio[0]: "ignore"`)
- **stdout/stderr captured**: Piped to parent for collection (`stdio[1,2]: "pipe"`)
- **No explicit fd closing**: Node.js default behavior — inherits other file descriptors
- **Parent sockets/pipes accessible**: Child can access parent's open network connections, database handles, etc.
- **Security risk**: Adapter processes may access unintended parent file descriptors
### Attack Surface
- CLI tools have **full user-level filesystem access**
- Can spawn additional processes (not tracked/limited)
- Network requests unrestricted
- Resource consumption relies on OS-level limits
## Worker Process Management
### Workflow Isolation
- Each workflow type gets dedicated worker process
- Worker processes handle multiple concurrent threads (runIds)
- Kill flags enable per-thread cancellation without killing worker
- Graceful shutdown waits up to 10 seconds for in-flight operations
#### Cross-RunId Contamination Risks
**Shared mutable state** poses contamination risks between concurrent runIds:
- **`process.env` mutations**: Environment changes affect all subsequent runIds in same worker
- **`require.cache` pollution**: Module cache shared across all runIds — side effects persist
- **Global variables**: Any global state mutations from one runId visible to others
- **`process.cwd()` changes**: Working directory changes affect entire worker process
- **File descriptors**: Open files/sockets shared between runId executions
**No runId-specific scoping** implemented:
- Worker reuses single Node.js process for efficiency
- Each role execution sees cumulative environment from previous runIds
- **Mitigation relies on adapter discipline** — clean implementations avoid global mutations
### Error Handling
- Adapter failures don't crash the worker process
- Timeout/abort errors are isolated to specific role execution
- Worker process survives adapter failures and continues serving other threads
## Configuration
```yaml
# Example nerve.yaml configuration for timeout overrides
workflows:
my-workflow:
roles:
coder:
adapter:
type: cursor
timeout: 600000 # 10 minutes in milliseconds
```
Timeout configuration happens at the adapter creation level, not as a system-wide sandbox policy.
+24 -3
View File
@@ -5,13 +5,23 @@ Adapter = capability. Role = scenario. Workflows declare adapters directly via i
## AgentFn Protocol
```ts
type AgentFn = (prompt: string, context: WorkflowContext) => Promise<string>
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>
```
- Input: prompt + context (start frame, messages, workdir, AbortSignal)
- Output: raw string — structured extraction is separate
- Input: thread context (`{ threadId, start, steps }`) + system prompt (role identity)
- Output: **single-shot `Promise<string>`** — no streaming support
- Adapter handles tool-specific details internally
### Streaming Limitations
The `AgentFn` protocol does **not** support streaming responses (`AsyncIterable<string>` or `ReadableStream`). It's strictly limited to single-shot `Promise<string>` returns.
For long-running or incremental agent outputs:
- CLI tools buffer full output until completion
- Timeout enforcement via `timeoutMs` (default 300s)
- No intermediate results exposed to workflow logic
- Progress indication happens at the CLI tool level only
## Available Adapters
| Package | Adapter | Tool |
@@ -45,3 +55,14 @@ extract:
```
Two-level merge: global → role override. Retry once on parse failure (feeds error back to LLM), then throw `ExtractError`.
## Error Handling
When adapters' underlying CLI tools (e.g., `cursor-agent` or `hermes`) fail, errors are surfaced **synchronously via rejection** with no fallback/retry logic:
- **Missing/unavailable tool**: `spawn_failed` error when CLI binary not found in `$PATH`
- **Non-zero exit code**: `non_zero_exit` error with captured stdout/stderr
- **Timeout**: `timeout` error when execution exceeds configured `timeoutMs`
- **Abort signal**: `aborted` error when `AbortSignal` triggers cancellation
All errors are immediately thrown as `Error` instances with descriptive messages (e.g., `"cursor-agent: exitCode=7 stdout=... stderr=..."`). No automatic retries or fallback adapters.
+19 -6
View File
@@ -5,20 +5,22 @@ Observation engine for autonomous agents — sense the world, react to changes,
## Core Pipeline
```
External World → Sense → Signal → Reflex → Workflow → Log
External World → Sense → Signal → Workflow → Log
compute() returns
{ signal, workflow }
```
Causality is **one-directional**. Logs are the end of the chain — they cannot trigger Reflexes (prevents feedback loops).
Causality is **one-directional**. Logs are the end of the chain — they cannot trigger further Senses (prevents feedback loops).
## Three Orthogonal Extension Points
## Two Extension Points
| Extension | Question | Nature |
|-----------|----------|--------|
| **Sense** | What to compute | `compute()` function |
| **Reflex** | When to compute | Declarative YAML (interval / on) |
| **Sense** | What to observe & when to react | `compute()` pure function + YAML config (interval / on) |
| **Workflow** | What to do | Roles + Moderator |
Each is independent. Reflex doesn't know compute internals, Sense doesn't know when it's triggered, Workflow doesn't know why it was started.
Senses own both the "what" (compute logic) and the "when" (config-driven scheduling). A Sense can trigger a Workflow directly by returning `{ signal, workflow: { name, prompt } }`.
## Two Event Types
@@ -31,3 +33,14 @@ Each is independent. Reflex doesn't know compute internals, Sense doesn't know w
- One worker per Workflow type (on-demand)
- Workers never talk to each other
- All user code runs in isolated Workers; kernel never loads user code directly
## Storage Systems
- **Log Store** — SQLite with WAL mode for audit trails and workflow state
- **Sense Databases** — Isolated SQLite per sense group for private data
- **Knowledge Store** — Vector search index for project context
- **Blob Store** — Content-addressable storage for large artifacts
## Signal Flow
Sense compute outputs are routed through signal routing logic that determines whether to emit a signal or trigger a workflow—never both simultaneously.
+10
View File
@@ -6,6 +6,8 @@
```bash
nerve init # scaffold a new workspace (nerve.yaml, senses/, workflows/)
nerve init --force # reinitialize workspace even if ~/.uncaged-nerve/ exists (preserves data/)
nerve init --from <git-url> # clone existing workspace from git repository
nerve validate # validate nerve.yaml config
nerve dev # run kernel foreground (development, Ctrl+C to stop)
nerve start # start daemon (background)
@@ -14,6 +16,14 @@ nerve status # check daemon health (uptime, senses, workflows)
nerve daemon # restart daemon (stop + start)
```
### Init Behavior
**Default `nerve init`**: Creates workspace at `~/.uncaged-nerve/`. If this directory already exists and is non-empty, **exits with error** requiring `--force` flag. No merge/overwrite logic — prevents accidental workspace destruction.
**Force mode `nerve init --force`**: Reinitializes workspace even if `~/.uncaged-nerve/` exists. **Preserves `data/` directory** (containing sense SQLite databases and logs) but overwrites all config files (`nerve.yaml`, `package.json`, etc.) and example senses.
**Git clone `nerve init --from <url>`**: Clones existing repository to `~/.uncaged-nerve/`. Requires empty target directory — fails if workspace already exists and is non-empty.
## Sense Management
```bash
+31
View File
@@ -24,6 +24,21 @@ type Config = { throttle?: string }
- `throw` only for programmer errors (bugs)
- No try-catch for flow control
### Result<T, E> Type
Defined in `@uncaged/nerve-core` (`packages/core/src/result.ts`):
```ts
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
```
**Discriminated union** with tagged `ok` field. Helper functions:
- `ok(value)``{ ok: true, value }`
- `err(error)``{ ok: false, error }`
**Exhaustive handling**: Pattern is `if (!result.ok) { handle error }` then access `result.value`.
No compiler enforcement - relies on manual discipline and TypeScript's flow control analysis.
## Naming
| Type | Style |
@@ -38,9 +53,25 @@ type Config = { throttle?: string }
- Always named exports, never default
- One module = one responsibility
### Module Naming Conventions
**Primary exports** use descriptive, unambiguous names:
- Functions: `createXxx()`, `parseXxx()`, `xxxAgent()` (e.g., `createCursorAdapter`, `cursorAgent`)
- Types: Domain-specific prefixes (e.g., `CursorAgentOptions`, `SenseComputeFn`, `ThreadContext`)
- Constants: `UPPER_SNAKE_CASE` with context (e.g., `DEFAULT_SENSE_SIGNAL_RETENTION`, `CURSOR_ADAPTER_DEFAULT_MS`)
**Avoiding ambiguity**:
- Package-scoped naming: `@uncaged/nerve-adapter-cursor` exports `cursorAgent`, `createCursorAdapter`
- Factory pattern: `createXxxAdapter()` for configurable instances, `xxxAdapter` for defaults
- Descriptive type prefixes prevent collision (e.g., `CursorAgentOptions` vs `HermesAgentOptions`)
## Async
- Always `async/await`, never `.then()` chains
- Use `AbortSignal` for cancellation: `AbortController` to create signals, pass to long-running operations
- `spawn-safe.ts` and adapter functions accept `abortSignal: AbortSignal | null` parameter
- On abort: child processes receive `SIGTERM`, async operations should check `signal.aborted`
- No enforced Biome/Vitest rules for AbortSignal usage (manual discipline required)
## No Dynamic Import
+14 -4
View File
@@ -19,10 +19,20 @@ nerve knowledge query --repo /path "query" # search specific repo
## Embedding
- Remote service: configured via `EMBED_SERVICE_URL` env var (self-hosted Cloudflare Worker + KV cache)
- Model: Dashscope text-embedding-v3 (1024 dims)
- Cache: content-addressable (sha256 of model+text), never expires
- Fallback: word-overlap scoring when embed service not configured
- **Default model**: Dashscope text-embedding-v3 (1024 dimensions)
- **Remote service**: configured via `EMBED_SERVICE_URL` env var (self-hosted Cloudflare Worker + KV cache)
- **Model configuration**: No mechanism to specify alternate models — hardcoded to text-embedding-v3 in remote service
- **Vector dimensions**: Fixed at 1024 (Float32Array, stored as 4096-byte Buffer blobs in SQLite)
- **Cache**: content-addressable (sha256 of model+text), never expires
- **Fallback**: word-overlap scoring when embed service not configured
### Configuration
The embedding model is **not configurable** through `knowledge.yaml` or other config files. The remote service at `embed.shazhou.workers.dev` uses Dashscope text-embedding-v3 exclusively. To use different models, you would need to:
1. Deploy your own embedding service compatible with the same API
2. Point `EMBED_SERVICE_URL` to your service
3. Ensure vector dimensions match (1024) or modify knowledge database schema
## Chunking
+40 -8
View File
@@ -2,18 +2,39 @@
A `compute()` function that samples or derives external data. The only first-class citizen in nerve.
## Behavior
## Contract
- Returns `T | null` — non-null emits a Signal, null is silent (no storage write, no signal, no downstream trigger)
- Each Sense has its own **independent SQLite database**
- Cross-sense reads are read-only via `peers` parameter
- Schema defined with Drizzle ORM (`schema.ts` is single source of truth)
Each sense module (`src/index.ts`) must export:
## Sense → Workflow
```ts
export { snapshots as table } from "./schema.ts"; // drizzle table for runtime to insert into
If `compute()` returns an object with `workflow: "name|maxRounds|prompt"`, the engine starts that workflow and does **not** emit a Signal. `workflow: null` or `""` means emit signal normally.
export async function compute(): Promise<ComputeResult<T>> { ... } // pure, no args
```
See `routeSenseComputeOutput` / `parseSenseWorkflowDirective` in `@uncaged/nerve-core`.
**Function Signature & Input Schema:**
- `compute()` is **parameterless** — no direct inputs, environment variables available
- No database access within compute — runtime provides isolated execution context
- Must be pure function (no side effects, no external API calls)
**Return Value Contract:**
- `ComputeResult<T>` = `null | { signal: T; workflow: WorkflowTrigger | null }`
- `null` → silent, no storage, no signal
- `{ signal: data, workflow: null }` → persist + emit signal
- `{ signal, workflow: WorkflowTrigger }` → persist + emit signal + trigger workflow
- Any other value → treated as `{ signal: value, workflow: null }`
**Error Handling & Serialization:**
- Exceptions caught by worker, logged as errors (no signal emitted)
- Signal payload must be JSON-serializable (passed via IPC)
- Invalid workflow triggers silently dropped (signal still emitted)
**Timeout & Scheduling Semantics:**
- Timeout priority: explicit config → AbortSignal → DEFAULT_TIMEOUT_MS (30s)
- Enforced via `Promise.race()` with timeout promise
- Grace period can trigger `process.exit(1)` after timeout (kills worker group)
- Interval translation: YAML config values used directly as milliseconds in `setInterval()`
- Jitter control: throttle mechanism prevents rapid-fire, single deferred trigger per throttle window
## Config (nerve.yaml)
@@ -27,3 +48,14 @@ senses:
interval: 30s # periodic trigger (optional)
on: [disk-pressure] # trigger on signals from other senses (optional)
```
## Manual Trigger Context
**`nerve sense trigger <name>`** sends IPC message to running daemon. The compute context is initialized as follows:
- **SQLite Database**: Opened in **read-write mode** at `data/senses/<name>.db`
- **Migrations**: All `*.sql` files in `senses/<name>/migrations/` applied in lexicographic order
- **Environment**: Inherits daemon process environment (no special secrets injection)
- **Arguments**: No runtime arguments or mock inputs supported — `compute()` is always pure function with no parameters
- **Isolation**: Runs in forked child process (worker) with full filesystem access within user permissions
- **Persistence**: Runtime automatically calls `db.insert(table).values(result.signal)` if compute returns non-null signal
+91
View File
@@ -0,0 +1,91 @@
# Signal Routing
Signal routing is the core mechanism that determines how Sense outputs flow through the Nerve system.
## Routing Logic
When a Sense `compute()` function returns non-null, the output goes through `routeSenseComputeOutput()` in `packages/core/src/sense-workflow-directive.ts`:
```
Sense compute() → non-null → routeSenseComputeOutput() → { signal, workflow }
kernel.ts → signal ALWAYS emitted + optional workflow start
```
## Two Output Formats
### 1. Explicit Format
```typescript
{
signal: any, // emitted as signal
workflow: { // optional workflow trigger
name: string,
maxRounds: number,
prompt: string,
dryRun: boolean
} | null
}
```
### 2. Shorthand Format
Any other value is treated as:
```typescript
{ signal: payload, workflow: null }
```
## Workflow Directive Parsing
## Concrete Routing Predicates
The routing decision is implemented in `routeSenseComputeOutput()` using these exact matching criteria:
### 1. Explicit Format Detection
```typescript
if (isPlainRecord(payload) && Object.hasOwn(payload, "signal"))
```
- Payload must be a plain object
- Must have `signal` property (any value)
- Workflow extracted from `workflow` property or defaults to null
### 2. Workflow Validation
When workflow is non-null, it's validated via `parseWorkflowTrigger()`:
- `name`: non-empty string (trimmed)
- `maxRounds`: positive integer >= 1
- `prompt`: string
- `dryRun`: boolean
**Critical behavior**: Invalid workflows are silently dropped (become null) but signal emission continues. This prevents malformed workflow config from blocking signals.
### 3. Fallback to Shorthand
Any value that doesn't match explicit format becomes:
```typescript
{ signal: payload, workflow: null }
```
## Processing Flow
```typescript
// In kernel.ts handleSenseWorkerSignal()
const { signal: signalPayload, workflow } = routeResult.value;
// Signal is ALWAYS emitted when compute returns non-null
bus.emit({ id, senseId, payload: signalPayload, timestamp });
// Workflow is started ONLY if workflow is non-null
if (workflow !== null) {
workflowManager.startWorkflow(workflow.name, { ... });
}
```
## Legacy String Format (Deprecated)
The old `"name|maxRounds|prompt"` string format is converted to the structured format internally but should not be used in new code.
## Key Behaviors
1. **Signal priority**: Every non-null compute result emits a signal, regardless of workflow
2. **Additive behavior**: Valid workflow triggers are executed in addition to signal emission
3. **Failure tolerance**: Invalid workflow directives are silently ignored, signal still emits
4. **Structure-based routing**: No complex predicates - simply checks object structure and property existence
This routing mechanism ensures clean separation between perception (signals) and action (workflows) while maintaining backward compatibility.
+132
View File
@@ -0,0 +1,132 @@
# Storage Layer
Nerve uses multiple storage systems designed for different data types and access patterns.
## Core Storage Components
### 1. Log Store (`logs.db`)
Append-only audit trail implemented in SQLite with WAL mode.
**Schema:**
- `logs` — all system events (signals, workflow transitions, sense outputs)
- `meta` — key-value store for system metadata
- `workflow_runs` — materialized view of workflow execution state
**Key Features:**
- Atomic workflow state updates via transactions
- Thread message persistence for crash recovery
- Configurable log archival to JSONL files
- Full-text search across log entries
### 2. Sense Databases
Each sense group gets its own SQLite database for private state.
**Characteristics:**
- Isolated per sense group (e.g., `system-senses.db`)
- Managed by individual sense compute functions
- Drizzle ORM integration for schema management
- No cross-sense data sharing
### 3. Knowledge Store (`knowledge.db`)
Vector-enabled search index for project context.
**Contents:**
- Chunked source files with embeddings
- Curated knowledge cards from `.knowledge/`
- Semantic search capabilities
- Global vs. repo-scoped search modes
### 4. Blob Store (CAS)
Content-addressable storage for large artifacts.
**Design:**
- SHA-256 based file naming
- Automatic deduplication
- Used for workflow artifacts and large payloads
## Consistency & Isolation Mechanisms
### SQLite WAL Mode
All SQLite databases use `PRAGMA journal_mode=WAL` for:
- **Writer-reader concurrency** — readers don't block writers
- **Atomic writes** — each transaction is fully applied or rolled back
- **Crash recovery** — WAL provides consistent state after crashes
### Transaction Management
#### Log Store Transactions
Uses `BEGIN IMMEDIATE` transactions (`packages/store/src/log-store.ts`):
```typescript
function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
db.exec("BEGIN IMMEDIATE"); // Exclusive write lock
try {
const result = fn();
db.exec("COMMIT");
return result;
} catch (e) {
db.exec("ROLLBACK");
throw e;
}
}
```
**Key Operations:**
- `upsertWorkflowRun()` — atomically writes log entry + workflow state
- `archiveLogs()` — transactional export + delete + watermark update
#### Sense Database Isolation
- Each sense group has its own SQLite file (e.g., `system-senses.db`)
- No cross-sense transactions or coordination required
- Independent schema migrations per sense
- Private `_signals` table for signal history retention
### Process-Level Isolation
#### Worker Process Architecture
- **One worker per sense group** — prevents data races within group
- **One worker per workflow type** — isolated execution contexts
- **No shared memory** — all communication via IPC messages
#### Concurrency Control
Workflow manager enforces limits per workflow:
```yaml
workflows:
my-workflow:
concurrency: 2 # Max parallel threads
overflow: "queue" # or "drop"
maxQueue: 10 # Queue depth limit
```
### Consistency Guarantees & Failure Modes
**Strong Consistency (Single Database)**:
1. **Within Log Store** — ACID transactions with immediate consistency
2. **Within Sense DB** — WAL mode ensures atomic commits per database
3. **Workflow State**`upsertWorkflowRun()` atomically updates log + materialized view
**No Cross-Database Consistency**:
- No distributed transactions across multiple SQLite files
- Log Store and Sense Databases can temporarily diverge during failures
- Signal emission and workflow triggering are separate, non-atomic operations
**Failure Recovery Mechanisms**:
- **Sense worker crash**: State rebuilt from sense SQLite database on respawn
- **Workflow worker crash**: Thread state recovered from log store message history
- **Kernel crash**: All workers respawned, state recovered from persistent stores
- **Log Store corruption**: WAL recovery on database open
- **Sense DB corruption**: Migrations re-run, `_signals` table rebuilt if needed
**Rollback Scenarios**:
- **Log write failure**: Transaction rolled back, no state changes persisted
- **Sense compute failure**: Error logged, no signal/workflow emitted
- **Workflow failure**: Thread marked as failed in materialized view
- **IPC failure**: Worker respawned, pending operations lost (not rolled back)
## Archive Strategy
Logs older than retention window (default 30 days) are:
1. Exported to `data/archive/logs/YYYY-MM-DD.jsonl`
2. Deleted from active database
3. Watermark updated to prevent re-processing
This keeps the active database size bounded while preserving audit trails.
+152
View File
@@ -0,0 +1,152 @@
# Worker Isolation
Nerve's worker architecture ensures complete isolation between different types of user code while maintaining system stability.
## Process Architecture
```
Kernel (Main Process)
├── Sense Worker (Group A) ── sense-1, sense-2
├── Sense Worker (Group B) ── sense-3, sense-4
├── Workflow Worker (cleanup) ── cleanup workflow instances
└── Workflow Worker (review) ── review workflow instances
```
## Isolation Boundaries
### 1. Sense Workers
- **One worker per sense group** (configured in `nerve.yaml`)
- Groups share a child process but have isolated execution contexts
- Crash in one sense doesn't affect other groups
- Each group has its own SQLite database
### 2. Workflow Workers
- **One worker per workflow type** (spawned on-demand)
- Multiple threads of the same workflow share a worker process
- Concurrency limits enforced at the workflow level
- Workers terminate when no active threads remain
### 3. Kernel Protection
- **User code never runs in kernel process**
- All `compute()` and workflow role functions run in workers
- Kernel only handles IPC, scheduling, and coordination
- System remains stable even with infinite loops or crashes in user code
## Worker Lifecycle
### Sense Workers
```
nerve daemon start → spawn worker per group → long-lived process
→ hot reload on file changes
→ respawn on crash
```
### Workflow Workers
```
workflow trigger → check existing worker → reuse or spawn
→ execute thread
→ terminate when idle
```
## Communication Patterns
### Kernel ↔ Sense Worker
- IPC via child process stdio
- JSON-formatted messages
- Worker reports signals back to kernel
- Bidirectional: kernel can request immediate computes
### Kernel ↔ Workflow Worker
- Similar IPC protocol
- Workflow definition loaded in worker
- Role execution results streamed back
- Thread state managed in kernel
## Resource Limits & Control
### Timeout Enforcement
Configurable timeouts per sense (in `nerve.yaml`):
```yaml
senses:
my-sense:
timeout: 30000 # Execution timeout (ms)
gracePeriod: 5000 # Grace period before hard kill
```
**Timeout Implementation:**
- `AbortController` for async operations
- `Promise.race()` between compute and timeout
- Grace period triggers `process.exit(1)` to kill entire worker group
### Memory & CPU Limits
**No Application-Level Resource Quotas**:
- No memory caps, CPU throttling, or disk I/O limits enforced by Nerve
- Workers can consume arbitrary system resources until OS limits
- No cgroup/container isolation — full filesystem access within user permissions
- No syscall filtering (no seccomp restrictions)
**OS-Level Constraints Only**:
- Process memory limited by system `ulimit -m`
- CPU usage bounded by scheduler only
- Network requests unrestricted
- Can spawn additional processes (not tracked by Nerve)
### Concurrency Control
#### Sense Workers
- One active compute per sense at a time (serialized via promise chains)
- No memory sharing between sense groups
- Crash isolation: one sense crash doesn't affect other groups
#### Workflow Workers
Per-workflow limits configured in `nerve.yaml`:
```yaml
workflows:
my-workflow:
concurrency: 2 # Max parallel threads
overflow: "drop" # or "queue"
maxQueue: 10 # Queue size limit
```
### Process Management
#### Signal Handling
Workers ignore session broadcast signals (SIGINT/SIGTERM):
```typescript
// Workers ignore terminal signals; kernel coordinates shutdown
process.on("SIGINT", () => {});
process.on("SIGTERM", () => {});
```
#### Graceful Shutdown & State Handoff
**Sense Workers**:
- IPC `shutdown` message → `process.exit(0)` (immediate)
- No graceful termination period for senses
- State rebuilt from SQLite on respawn (no handoff needed)
**Workflow Workers**:
- IPC `shutdown` → wait for in-flight threads to complete
- Drain timeout: `WORKER_SHUTDOWN_TIMEOUT_MS` (10s)
- If threads don't complete → `SIGKILL` force termination
- Thread state preserved in log store for crash recovery
**State Handoff Mechanism**:
- No explicit state transfer between old/new workers
- Sense workers: SQLite database contains full state
- Workflow workers: Log store contains thread message history
- Kernel coordinates recovery via `recoverThreadsForWorker()`
## Failure Handling
### Worker Crashes
- **Sense workers**: Automatic respawn after 1s delay, state rebuilt from DB
- **Workflow workers**: Crash recovery from log store thread messages
- **Kernel protection**: Main process continues, marks affected runs as crashed
- **Crash limits**: Max 5 crashes per workflow in 60s window (prevents infinite respawn)
### Resource Exhaustion
- **Memory**: Worker process killed by OS, kernel respawns automatically
- **Compute timeout**: Grace period → hard kill → respawn
- **Infinite loops**: Timeout enforcement prevents hanging indefinitely
This architecture allows Nerve to run untrusted or experimental code safely while maintaining system availability.
+66 -3
View File
@@ -6,8 +6,8 @@ Stateful multi-step execution driven by Roles and a Moderator.
- **Workflow** — definition with concurrency strategy
- **Thread** — one execution instance, unique `runId`
- **Role** — executes actions (has side effects). `(start, messages) → { content, meta }`
- **Moderator** — pure routing function. `(context) → next role | END`
- **Role** — executes actions (has side effects). `(ctx: ThreadContext) → Promise<RoleResult<M>>`
- **Moderator** — pure routing function. `(ctx: ThreadContext) → next role | END`
## Thread Lifecycle
@@ -54,6 +54,69 @@ const workflow: WorkflowDefinition<MyMeta> = {
```
- `adapter: AgentFn` — direct function reference
- `prompt: string | ((start, messages) => Promise<string>)` — static or dynamic
- `prompt: string | ((ctx: ThreadContext) => Promise<string>)` — static or dynamic
- `meta: z.ZodType<M>` — Zod schema, directly (no wrapper needed)
- `extract: LlmExtractorConfig` — provider for structured extraction
## Runtime Enforcement Mechanisms
### Role Authority & Validation
**Role Function Lookup**:
- Roles accessed via `def.roles[nextRole]` dictionary lookup
- Unknown roles trigger immediate workflow error (`Unknown role: ${nextRole}`)
- No dynamic role registration during execution
**Result Validation** (`validateRoleResult()`):
```typescript
// Required return shape from every role function
{ content: string, meta: Record<string, unknown> }
```
- `content` must be string (non-string → workflow error)
- `meta` must be plain object (array/null/primitive → workflow error)
- Validation failure terminates thread immediately
### Moderator Authority & Routing Control
**Next Role Selection**:
- Moderator must return role name from `roles` keys OR `END` symbol
- Called after every role completion (receives full context)
- No validation of role name until execution attempt
- Pure function constraint: cannot perform side effects
**Causal Chain Integrity**:
- Moderator receives immutable **ThreadContext**: `{ threadId, start, steps }`
- Steps array contains ALL role outputs in chronological order
- No role can modify prior steps or start metadata
- Thread context built from log store on crash recovery
### Unauthorized Command Event Prevention
**Message Flow Control**:
- Role functions have NO direct access to kernel IPC
- All outputs flow through `sendWorkflowMessage()` wrapper
- Worker process validates messages before kernel transmission
- No direct log store database access from roles
**Process Isolation**:
- Roles execute in forked worker processes (not kernel)
- File system access limited to user permissions
- No network isolation (roles can make arbitrary HTTP calls)
- Worker has read/write access to workflow workspace only
### Concurrent Thread Management
**Kill Flag Implementation**:
```typescript
type KillFlag = { value: boolean };
// Checked before role execution and after completion
if (killFlag.value) {
sendThreadEvent(runId, "killed", { exitCode: 137 });
return;
}
```
**Concurrency Enforcement**:
- Workflow manager enforces per-workflow limits in kernel
- Excess threads queued/dropped per overflow policy
- No role can spawn additional threads (no access to workflow manager)
+34 -10
View File
@@ -3,7 +3,7 @@
## Core Concepts
```
External World → Sense → Signal → Reflex → Workflow → Log
External World → Sense → Signal → Workflow → Log
↑ ↑
"what to observe" "what to do"
```
@@ -14,19 +14,18 @@ External World → Sense → Signal → Reflex → Workflow → Log
| Concept | What it is |
|---------|-----------|
| **Sense** | A `compute()` function that samples or derives data. Returns `T \| null` — non-null emits a Signal, null is silent. Each Sense has its own SQLite database. |
| **Sense** | A `compute()` function that samples or derives data. Returns `ComputeResult<T>` — non-null emits a Signal (and optionally triggers a Workflow), null is silent. Each Sense has its own SQLite database. Scheduling (interval, on) is configured in nerve.yaml. |
| **Signal** | A notification emitted when a Sense returns non-null. Pure fact, no intent. Distributed via an in-memory Signal Bus. Not persisted. |
| **Reflex** | A declarative trigger (YAML) connecting Senses to actions. Trigger types: `interval` (periodic), `on` (react to Signals). Action types: trigger a Sense, or start a Workflow. |
| **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 Reflexes** — prevents feedback loops. |
| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Reflex Scheduler, Process Manager, Workflow Manager. Never loads user code directly — all user code runs in isolated Workers. |
| **Log** | Immutable audit trail. Records executions, state transitions, errors. Cannot trigger Senses — prevents feedback loops. |
| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Process Manager, Workflow Manager. 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
- **Three orthogonal extension points**: Sense (what to compute), Reflex (when to compute), Workflow (what to do)
- **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 → Signal → Reflex → Action + Log. Logs are the end of the chain.
- **Causality is one-directional**: External world → Sense → Signal → Workflow + Log. Logs are the end of the chain.
@@ -94,9 +93,34 @@ For mutually exclusive fields, use discriminated unions:
```typescript
// ✅ Good
type ReflexConfig =
| { kind: "sense"; sense: string; interval: string | null; on: string[] | null }
| { kind: "workflow"; workflow: string; on: string[] | null };
type ComputeResult<T> =
| null
| { signal: T; workflow: WorkflowTrigger | null };
```
### 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
+187
View File
@@ -0,0 +1,187 @@
# RFC-004: Package Architecture — Shareable Workflows, Roles & Senses
**Author:** 小橘 🍊(NEKO Team)
**Status:** Draft
**Created:** 2026-04-29
## Summary
Make workflows, roles, and senses publishable as lightweight npm packages. Workspaces become pure configuration — selecting packages, wiring adapters, and providing credentials. No builtin workflows in the nerve core.
## Motivation
Currently, workflows like `develop-sense` and `develop-workflow` live inside the workspace (`~/.uncaged-nerve/workflows/`). This creates problems:
1. **No sharing** — every workspace duplicates the same workflow code
2. **No versioning** — upgrading a workflow means manual file edits
3. **Builtin is a trap** — if we bake workflows into nerve core, they require adapters and LLM providers that may not be installed. A fresh `nerve` install on a bare machine would fail to load builtins.
4. **Roles are already shared**`_shared/workspace-committer.ts` proves the pattern works; we just need to formalize it as packages
The adapter pattern (`@uncaged/nerve-adapter-hermes`, `@uncaged/nerve-adapter-cursor`) already established the precedent: infrastructure as packages, workspace as wiring.
## Design
### Package Taxonomy
```
@uncaged/nerve-core # types, engine
@uncaged/nerve-daemon # runtime
@uncaged/nerve-workflow-utils # createRole, decorateRole, withDryRun, onFail, etc.
# Adapters (existing)
@uncaged/nerve-adapter-hermes
@uncaged/nerve-adapter-cursor
# Workflows (new)
@uncaged/nerve-workflow-solve-issue
@uncaged/nerve-workflow-develop-sense
@uncaged/nerve-workflow-develop-workflow
# Shared Roles (new)
@uncaged/nerve-role-committer # workspace committer (branch, commit, push)
@uncaged/nerve-role-reviewer # code review role
@uncaged/nerve-role-publisher # PR creation role
# Senses (existing pattern, formalized)
@uncaged/nerve-sense-cpu-usage
@uncaged/nerve-sense-disk-usage
```
### Package Contract
Each package type exports a factory function:
#### Workflow Package
```ts
// @uncaged/nerve-workflow-develop-sense
import type { AgentFn, WorkflowDefinition } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
export type SenseMeta = { /* ... */ };
export type CreateDevelopSenseDeps = {
defaultAdapter: AgentFn;
adapters?: Partial<Record<keyof SenseMeta, AgentFn>>;
extract: LlmExtractorConfig;
cwd: string;
};
export function createDevelopSenseWorkflow(deps: CreateDevelopSenseDeps): WorkflowDefinition<SenseMeta>;
```
Key design decisions:
- `defaultAdapter` + optional `adapters` override per role — via `Partial<Record<keyof Meta, AgentFn>>`
- Adapter keys are derived from `Meta` type — adding/removing a role automatically updates the adapter map
- Roles that don't need an agent simply don't appear in `adapters` (the `Partial` allows this)
#### Role Package
```ts
// @uncaged/nerve-role-committer
import type { AgentFn, Role } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole, decorateRole, withDryRun, onFail } from "@uncaged/nerve-workflow-utils";
export type CommitterMeta = { committed: boolean };
export function createCommitterRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<CommitterMeta> {
const inner = createRole(adapter, prompt, committerMetaSchema, extract);
return decorateRole(inner, [
withDryRun({ label: "committer", meta: { committed: true } }),
onFail({ label: "committer", meta: { committed: false } }),
]);
}
```
Roles compose with the decorator chain from `workflow-utils`:
- `withDryRun` — skip execution in dry-run mode
- `onFail` — catch errors into structured failure results
- `decorateRole(role, [...])` — apply decorators left-to-right
- Custom `RoleDecorator<M>` can be created for project-specific needs
#### Sense Package
```ts
// @uncaged/nerve-sense-cpu-usage
export const senseName = "cpu-usage";
export const schema = { /* drizzle schema */ };
export async function compute(ctx: SenseContext): Promise<SenseResult>;
```
### Workspace as Configuration
The workspace becomes a thin wiring layer:
```
~/.uncaged-nerve/
nerve.yaml # senses, extract config
package.json # depends on workflow/role/adapter packages
workflows/
develop-sense/
index.ts # ~10 lines: import package, wire adapters, export
solve-issue/
index.ts # same pattern
```
A typical `index.ts`:
```ts
import { createDevelopSenseWorkflow } from "@uncaged/nerve-workflow-develop-sense";
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
import { cursorAdapter } from "@uncaged/nerve-adapter-cursor";
export default createDevelopSenseWorkflow({
defaultAdapter: hermesAdapter,
adapters: { planner: cursorAdapter, coder: cursorAdapter },
extract: { provider: { apiKey, baseUrl, model } },
cwd: nerveRoot,
});
```
### What Stays in Workspace
- **Custom workflows** — project-specific workflows that aren't general enough to share
- **Custom senses** — project-specific metrics
- **Configuration** — adapter selection, credentials, `nerve.yaml`
- **Overrides** — a workspace can always write its own role/workflow instead of using a package
### Dependency Rules
```
nerve-core ← no deps on other nerve packages
nerve-workflow-utils ← depends on nerve-core
nerve-adapter-* ← depends on nerve-core
nerve-role-* ← depends on nerve-core, nerve-workflow-utils
nerve-workflow-* ← depends on nerve-core, nerve-workflow-utils, may depend on nerve-role-*
nerve-sense-* ← depends on nerve-core
nerve-daemon ← depends on nerve-core, nerve-store
```
Workflow packages depend on role packages (not adapters). Adapters are injected at the workspace level.
### Migration Path
1. **Phase 1: Extract role packages** — Start with `@uncaged/nerve-role-committer` (already `_shared/workspace-committer.ts`). Publish, update workspace to import from package.
2. **Phase 2: Extract workflow packages** — Move `develop-sense` and `develop-workflow` to packages. Workspace `index.ts` becomes pure wiring.
3. **Phase 3: Sense packages** — Formalize sense packaging (lower priority, senses are already self-contained directories).
4. **Phase 4: Community** — Document the package contract so others can publish workflows/roles/senses.
### Not in Scope
- **No builtin workflows** — nerve core ships zero workflows. All workflows are packages installed by the workspace.
- **No workflow marketplace/registry** — just npm packages. `pnpm add @uncaged/nerve-workflow-solve-issue`.
- **No nerve.yaml workflow declaration** — workflows are still TypeScript entry points. The daemon discovers them the same way it does today.
## Open Questions
1. **Monorepo vs separate repos?** — Should workflow/role packages live in the nerve monorepo or separate repos? Monorepo is easier for coordinated releases; separate repos allow independent versioning.
2. **Sense package format** — Senses currently bundle with esbuild. Should sense packages ship pre-bundled or as TypeScript source?
3. **Version coupling** — How tightly should workflow packages pin `nerve-core`? Peer deps with semver range?
## Prior Art
- Adapter packages (`@uncaged/nerve-adapter-*`) — established the factory + injection pattern
- `_shared/workspace-committer.ts` — proved roles can be shared across workflows
- `createRole` / `decorateRole` / `withDryRun` / `onFail` in `workflow-utils` — building blocks that role packages compose
- `defaultAdapter` + `Partial<Record<keyof Meta, AgentFn>>` pattern — adapter injection without coupling
+13 -7
View File
@@ -1,4 +1,4 @@
import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core";
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core";
export type CursorAgentMode = "plan" | "ask" | "default";
@@ -84,22 +84,28 @@ function throwCursorSpawnError(error: SpawnError): never {
/** Default adapter config: model auto-selection and 300s wall-clock cap (milliseconds). */
const CURSOR_ADAPTER_DEFAULT_MS = 300_000;
export type CursorAdapterConfig = AgentConfig & {
/** When set, passes `--mode=ask` or `--mode=plan` to `cursor-agent` (default runs without extra mode). */
mode?: CursorAgentMode;
};
/**
* Builds a Cursor CLI `AgentFn` from adapter config (model, timeout).
*/
export function createCursorAdapter(config: AgentConfig): AgentFn {
export function createCursorAdapter(config: CursorAdapterConfig): AgentFn {
const timeoutMs = config.timeout;
const mode = config.mode ?? "default";
return async (prompt: string, context: WorkflowContext): Promise<string> => {
return async (_ctx: ThreadContext, prompt: string): Promise<string> => {
const run = await cursorAgent({
prompt,
mode: "default",
mode,
model: config.model,
cwd: context.workdir,
cwd: process.cwd(),
env: null,
timeoutMs,
dryRun: context.start.meta.dryRun,
abortSignal: context.signal,
dryRun: false,
abortSignal: null,
});
if (!run.ok) {
throwCursorSpawnError(run.error);
+4 -4
View File
@@ -1,4 +1,4 @@
import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core";
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core";
/**
@@ -96,7 +96,7 @@ export function createHermesAdapter(config: AgentConfig): AgentFn {
const modelFromConfig = config.model === "auto" ? null : config.model;
const timeoutMs = config.timeout;
return async (prompt: string, context: WorkflowContext): Promise<string> => {
return async (_ctx: ThreadContext, prompt: string): Promise<string> => {
const run = await hermesAgent({
prompt,
model: modelFromConfig,
@@ -106,8 +106,8 @@ export function createHermesAdapter(config: AgentConfig): AgentFn {
maxTurns: HERMES_ADAPTER_DEFAULT_MAX_TURNS,
env: null,
timeoutMs,
dryRun: context.start.meta.dryRun,
abortSignal: context.signal,
dryRun: false,
abortSignal: null,
});
if (!run.ok) {
throwHermesSpawnError(run.error);
@@ -33,9 +33,11 @@ describe("buildWorkflowScaffold", () => {
expect(indexTs).toContain("@uncaged/nerve-core");
});
it("root index wires moderator and END", () => {
it("root index wires moderator with ThreadContext and END", () => {
const { indexTs } = buildWorkflowScaffold("test");
expect(indexTs).toContain("moderator");
expect(indexTs).toContain("ThreadContext");
expect(indexTs).toContain("ctx.steps.length");
expect(indexTs).toContain("END");
});
@@ -46,9 +48,13 @@ describe("buildWorkflowScaffold", () => {
expect(indexTs).toContain("./roles/main/index.js");
});
it("main role module exports mainRole function", () => {
it("main role module exports mainRole with ThreadContext", () => {
const { roleMainIndexTs } = buildWorkflowScaffold("test");
expect(roleMainIndexTs).toContain("export async function mainRole");
expect(roleMainIndexTs).toContain("ThreadContext");
expect(roleMainIndexTs).toContain("RoleResult");
expect(roleMainIndexTs).not.toContain("StartStep");
expect(roleMainIndexTs).not.toContain("WorkflowMessage");
});
it("uses different names per call", () => {
@@ -1,5 +1,5 @@
/**
* RFC-003 Phase 5: nerve validate — WorkflowSpec adapter usage and extract.
* RFC-003 Phase 5: nerve validate — workflow adapter usage and extract.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -38,9 +38,8 @@ describe("validateAgentConfigurationLayer", () => {
writeFileSync(
join(nerveRoot, "workflows", "demo", "src", "index.ts"),
`
import type { WorkflowSpec } from "@uncaged/nerve-core";
const adapter = async () => "";
const spec: WorkflowSpec<{ r: { x: number } }> = {
const spec = {
name: "demo",
roles: {
r: { adapter: adapter, prompt: "p", meta: {} as never },
+6 -8
View File
@@ -52,7 +52,7 @@ export function buildWorkflowScaffold(name: string): WorkflowScaffoldFiles {
}
function buildWorkflowIndexTs(name: string): string {
return `import type { WorkflowDefinition } from "@uncaged/nerve-core";
return `import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { mainRole } from "./roles/main/index.js";
@@ -64,8 +64,8 @@ const workflow: WorkflowDefinition<Record<"main", MainMeta>> = {
roles: {
main: mainRole,
},
moderator({ steps }) {
if (steps.length === 0) {
moderator(ctx: ThreadContext<Record<"main", MainMeta>>) {
if (ctx.steps.length === 0) {
return "main";
}
return END;
@@ -77,18 +77,16 @@ export default workflow;
}
function buildWorkflowMainRoleIndexTs(name: string): string {
return `import type { RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
return `import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
/**
* Main role — implement LLM calls, scripts, HTTP, etc.
* Optional: align behavior with \`prompt.md\` in this directory.
*/
export async function mainRole(
start: StartStep,
messages: WorkflowMessage[],
ctx: ThreadContext,
): Promise<RoleResult<Record<string, unknown>>> {
void start;
void messages;
void ctx;
// TODO: implement your role logic here
return {
content: "${name} started",
@@ -8,7 +8,7 @@ import { join } from "node:path";
import type { NerveConfig } from "@uncaged/nerve-core";
/**
* Detects RoleSpec `adapter:` usage in workflow TypeScript sources.
* Detects `adapter:` usage in workflow TypeScript sources (e.g. createRole wiring).
* NOTE: This regex can match occurrences inside comments.
*/
const WORKFLOW_SPEC_ADAPTER_PATTERN = /adapter:\s*[a-zA-Z_$]/;
@@ -26,7 +26,7 @@ function collectTsSourceFiles(dir: string, acc: string[]): void {
}
/**
* Returns true when any workflow `src` tree appears to use WorkflowSpec roles with adapters.
* Returns true when any workflow `src` tree appears to use roles with adapters.
*/
export function workflowSourcesDeclareAdapterRoles(nerveRoot: string): boolean {
const workflowsRoot = join(nerveRoot, "workflows");
@@ -66,7 +66,7 @@ export function validateAgentConfigurationLayer(
return {
ok: false,
message:
"extract: required when WorkflowSpec roles use adapters (configure extract.provider and extract.model)",
"extract: required when workflow roles use adapters (configure extract.provider and extract.model)",
};
}
+1
View File
@@ -20,6 +20,7 @@
"test": "vitest run"
},
"dependencies": {
"drizzle-orm": "1.0.0-beta.23-c10d10c",
"yaml": "^2.8.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseNerveConfig } from "../parse-nerve-config.js";
import { parseNerveConfig } from "../config.js";
const VALID_CONFIG = `
senses:
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseDaemonIpcRequest } from "../daemon-ipc-protocol.js";
import { parseDaemonIpcRequest } from "../daemon.js";
describe("parseDaemonIpcRequest", () => {
it("parses trigger-workflow", () => {
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseKnowledgeYaml } from "../knowledge-config.js";
import { parseKnowledgeYaml } from "../config.js";
describe("parseKnowledgeYaml", () => {
it("parses include and exclude glob lists", () => {
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseWorkflowTrigger, routeSenseComputeOutput } from "../sense-workflow-directive.js";
import { parseWorkflowTrigger, routeSenseComputeOutput } from "../sense.js";
describe("parseWorkflowTrigger", () => {
it("accepts a valid trigger object", () => {
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { spawnSafe } from "../spawn-safe.js";
import { spawnSafe } from "../util.js";
describe("spawnSafe", () => {
it("passes argv literally without shell interpretation (injection-safe)", async () => {
-5
View File
@@ -1,5 +0,0 @@
/**
* Agent adapter ids referenced by tooling / docs (RFC-003).
* Workflows import adapter packages directly; echo may be used in tests via a small factory.
*/
export const KNOWN_AGENT_ADAPTER_IDS = ["echo", "cursor", "hermes", "codex"] as const;
@@ -21,3 +21,9 @@ export class ExtractError extends Error {
Object.setPrototypeOf(this, new.target.prototype);
}
}
/**
* Agent adapter ids referenced by tooling / docs (RFC-003).
* Workflows import adapter packages directly; echo may be used in tests via a small factory.
*/
export const KNOWN_AGENT_ADAPTER_IDS = ["echo", "cursor", "hermes", "codex"] as const;
+399 -2
View File
@@ -1,5 +1,7 @@
/** Default max rows kept in each sense's `_signals` SQLite table (see `retention` on `SenseConfig`). */
export const DEFAULT_SENSE_SIGNAL_RETENTION = 10_000;
import { parse } from "yaml";
import { type Result, err, isPlainRecord, ok, parseDurationStringToMs } from "./util.js";
import { DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
export type SenseConfig = {
group: string;
@@ -75,3 +77,398 @@ export type NerveConfig = {
/** Global extract defaults; `null` when the section is omitted. */
extract: ExtractConfig | null;
};
export type KnowledgeConfig = {
include: ReadonlyArray<string>;
exclude: ReadonlyArray<string>;
};
/** Default max rows kept in each sense's `_signals` SQLite table (see `retention` on `SenseConfig`). */
export const DEFAULT_SENSE_SIGNAL_RETENTION = 10_000;
function isValidGroupName(value: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(value);
}
function parseRetentionField(name: string, field: unknown): Result<number> {
if (field === undefined || field === null) {
return ok(DEFAULT_SENSE_SIGNAL_RETENTION);
}
if (typeof field !== "number" || !Number.isInteger(field) || field < 1) {
return err(new Error(`senses.${name}.retention: must be a positive integer`));
}
return ok(field);
}
function parseDurationField(field: unknown, label: string): Result<number | null> {
if (field === undefined || field === null) return ok(null);
if (typeof field !== "string") {
return err(
new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`),
);
}
const msResult = parseDurationStringToMs(field);
if (!msResult.ok) {
return err(new Error(`${label}: ${msResult.error.message}`));
}
return ok(msResult.value);
}
function validateSenseConfig(name: string, raw: unknown): Result<SenseConfig> {
if (!isPlainRecord(raw)) {
return err(new Error(`senses.${name}: must be an object`));
}
const obj = raw;
if (typeof obj.group !== "string" || obj.group.trim() === "") {
return err(new Error(`senses.${name}.group: required string`));
}
if (!isValidGroupName(obj.group)) {
return err(
new Error(
`senses.${name}.group: invalid name "${obj.group}" (only alphanumeric, underscore, hyphen allowed)`,
),
);
}
const throttleResult = parseDurationField(obj.throttle, `senses.${name}.throttle`);
if (!throttleResult.ok) return throttleResult;
const timeoutResult = parseDurationField(obj.timeout, `senses.${name}.timeout`);
if (!timeoutResult.ok) return timeoutResult;
const graceResult = parseDurationField(obj.grace_period, `senses.${name}.grace_period`);
if (!graceResult.ok) return graceResult;
const retentionResult = parseRetentionField(name, obj.retention);
if (!retentionResult.ok) return retentionResult;
const intervalResult = parseDurationField(obj.interval, `senses.${name}.interval`);
if (!intervalResult.ok) return intervalResult;
let on: string[] = [];
if (obj.on !== undefined && obj.on !== null) {
if (
!Array.isArray(obj.on) ||
!obj.on.every((item: unknown): item is string => typeof item === "string")
) {
return err(new Error(`senses.${name}.on: must be an array of strings`));
}
on = obj.on;
}
return ok({
group: obj.group,
throttle: throttleResult.value,
timeout: timeoutResult.value,
gracePeriod: graceResult.value,
retention: retentionResult.value,
interval: intervalResult.value,
on,
});
}
function parseEngineMaxRounds(obj: Record<string, unknown>): Result<number> {
if (obj.max_rounds === undefined || obj.max_rounds === null) {
return ok(DEFAULT_ENGINE_MAX_ROUNDS);
}
if (
typeof obj.max_rounds !== "number" ||
!Number.isInteger(obj.max_rounds) ||
obj.max_rounds < 1
) {
return err(new Error("max_rounds: must be a positive integer"));
}
return ok(obj.max_rounds);
}
function validateWorkflowConfig(name: string, raw: unknown): Result<WorkflowConfig> {
if (!isPlainRecord(raw)) {
return err(new Error(`workflows.${name}: must be an object`));
}
const obj = raw;
if (
typeof obj.concurrency !== "number" ||
!Number.isInteger(obj.concurrency) ||
obj.concurrency < 1
) {
return err(new Error(`workflows.${name}.concurrency: must be a positive integer`));
}
if (obj.overflow !== "drop" && obj.overflow !== "queue") {
return err(new Error(`workflows.${name}.overflow: must be "drop" or "queue"`));
}
if (obj.overflow === "drop") {
if (obj.max_queue !== undefined && obj.max_queue !== null) {
return err(new Error(`workflows.${name}: max_queue is not allowed with overflow "drop"`));
}
return ok({
concurrency: obj.concurrency,
overflow: "drop" as const,
});
}
// overflow: "queue"
let maxQueue = 100; // default
if (obj.max_queue !== undefined && obj.max_queue !== null) {
if (
typeof obj.max_queue !== "number" ||
!Number.isInteger(obj.max_queue) ||
obj.max_queue < 1
) {
return err(new Error(`workflows.${name}.max_queue: must be a positive integer`));
}
maxQueue = obj.max_queue;
}
return ok({
concurrency: obj.concurrency,
overflow: "queue" as const,
maxQueue,
});
}
function parseSenses(
obj: Record<string, unknown>,
): Result<{ senses: Record<string, SenseConfig> }> {
if (!isPlainRecord(obj.senses)) {
return err(new Error("senses: required object"));
}
const sensesRaw = obj.senses;
const senses: Record<string, SenseConfig> = {};
for (const [name, senseRaw] of Object.entries(sensesRaw)) {
const result = validateSenseConfig(name, senseRaw);
if (!result.ok) return result;
senses[name] = result.value;
}
return ok({ senses });
}
const DEFAULT_API_BIND_HOST = "127.0.0.1";
/** Hosts that may bind the HTTP API without `api.token` (loopback-only). */
function isLoopbackOnlyApiHost(host: string): boolean {
const h = host.trim();
return h === "127.0.0.1" || h.toLowerCase() === "localhost";
}
function parseApiTokenField(api: Record<string, unknown>): Result<string | null> {
if (api.token === undefined || api.token === null) {
return ok(null);
}
if (typeof api.token !== "string") {
return err(new Error("api.token: must be a string when provided"));
}
if (api.token.length === 0) {
return err(new Error("api.token: must not be empty when provided"));
}
return ok(api.token);
}
function parseApiHostField(api: Record<string, unknown>): Result<string> {
if (api.host === undefined || api.host === null) {
return ok(DEFAULT_API_BIND_HOST);
}
if (typeof api.host !== "string") {
return err(new Error("api.host: must be a string when provided"));
}
if (api.host.length === 0) {
return err(new Error("api.host: must not be empty when provided"));
}
return ok(api.host);
}
function parseApiConfig(obj: Record<string, unknown>): Result<NerveApiConfig> {
if (obj.api === undefined || obj.api === null) {
return ok({ port: null, token: null, host: DEFAULT_API_BIND_HOST });
}
if (!isPlainRecord(obj.api)) {
return err(new Error("api: must be an object if provided"));
}
const api = obj.api;
if (api.port === undefined || api.port === null) {
return ok({ port: null, token: null, host: DEFAULT_API_BIND_HOST });
}
if (
typeof api.port !== "number" ||
!Number.isInteger(api.port) ||
api.port < 1 ||
api.port > 65_535
) {
return err(new Error("api.port: must be an integer between 1 and 65535 if provided"));
}
const tokenResult = parseApiTokenField(api);
if (!tokenResult.ok) return tokenResult;
const hostResult = parseApiHostField(api);
if (!hostResult.ok) return hostResult;
if (!isLoopbackOnlyApiHost(hostResult.value) && tokenResult.value === null) {
return err(
new Error("api.host binds to non-loopback address, api.token is required for security"),
);
}
return ok({ port: api.port, token: tokenResult.value, host: hostResult.value });
}
function parseWorkflows(obj: Record<string, unknown>): Result<Record<string, WorkflowConfig>> {
if (obj.workflows === undefined || obj.workflows === null) return ok({});
if (!isPlainRecord(obj.workflows)) {
return err(new Error("workflows: must be an object if provided"));
}
const workflowsRaw = obj.workflows;
const workflows: Record<string, WorkflowConfig> = {};
for (const [name, wfRaw] of Object.entries(workflowsRaw)) {
const result = validateWorkflowConfig(name, wfRaw);
if (!result.ok) return result;
workflows[name] = result.value;
}
return ok(workflows);
}
function parseExtract(obj: Record<string, unknown>): Result<ExtractConfig | null> {
if (obj.extract === undefined || obj.extract === null) {
return ok(null);
}
if (!isPlainRecord(obj.extract)) {
return err(new Error("extract: must be an object if provided"));
}
const ext = obj.extract;
if (typeof ext.provider !== "string" || ext.provider.trim() === "") {
return err(new Error("extract.provider: required non-empty string"));
}
if (typeof ext.model !== "string" || ext.model.trim() === "") {
return err(new Error("extract.model: required non-empty string"));
}
return ok({ provider: ext.provider, model: ext.model });
}
export function parseNerveConfig(raw: string): Result<NerveConfig> {
let parsed: unknown;
try {
parsed = parse(raw);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return err(new Error(`YAML parse error: ${message}`));
}
if (!isPlainRecord(parsed)) {
return err(new Error("Config must be a YAML object"));
}
const obj = parsed;
const sensesResult = parseSenses(obj);
if (!sensesResult.ok) return sensesResult;
const { senses } = sensesResult.value;
// Legacy top-level `reflexes` is rejected; each sense carries `interval` / `on` for the sense scheduler.
if (Object.hasOwn(obj, "reflexes")) {
return err(
new Error(
"reflexes: top-level key is no longer supported; set `interval` and `on` on each sense under `senses.<name>`",
),
);
}
const workflowsResult = parseWorkflows(obj);
if (!workflowsResult.ok) return workflowsResult;
const maxRoundsResult = parseEngineMaxRounds(obj);
if (!maxRoundsResult.ok) return maxRoundsResult;
const apiResult = parseApiConfig(obj);
if (!apiResult.ok) return apiResult;
if (Object.hasOwn(obj, "agents")) {
return err(
new Error(
"agents: key is no longer supported — declare adapters on workflow roles (RFC-003)",
),
);
}
const extractResult = parseExtract(obj);
if (!extractResult.ok) return extractResult;
return ok({
maxRounds: maxRoundsResult.value,
senses,
workflows: workflowsResult.value,
api: apiResult.value,
extract: extractResult.value,
});
}
function parseStringList(field: unknown, label: string): Result<ReadonlyArray<string>> {
if (field === undefined || field === null) {
return ok([]);
}
if (!Array.isArray(field)) {
return err(new Error(`${label}: must be an array of strings`));
}
const out: string[] = [];
for (let i = 0; i < field.length; i++) {
const item = field[i];
if (typeof item !== "string" || item.length === 0) {
return err(new Error(`${label}[${String(i)}]: must be a non-empty string`));
}
out.push(item);
}
return ok(out);
}
/**
* Parse `knowledge.yaml` at the repo root (RFC-003 Knowledge Layer).
* `include` / `exclude` entries are glob patterns resolved against the repo root.
*/
export function parseKnowledgeYaml(raw: string): Result<KnowledgeConfig> {
let parsed: unknown;
try {
parsed = parse(raw);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return err(new Error(`YAML parse error: ${message}`));
}
if (parsed === undefined || parsed === null) {
return ok({ include: [], exclude: [] });
}
if (!isPlainRecord(parsed)) {
return err(new Error("knowledge.yaml: root must be a mapping"));
}
const includeResult = parseStringList(parsed.include, "include");
if (!includeResult.ok) {
return includeResult;
}
const excludeResult = parseStringList(parsed.exclude, "exclude");
if (!excludeResult.ok) {
return excludeResult;
}
return ok({
include: includeResult.value,
exclude: excludeResult.value,
});
}
@@ -1,33 +0,0 @@
import type { WorkflowStatus } from "./daemon-ipc-protocol.js";
import { isPlainRecord } from "./is-plain-record.js";
import type { SenseInfo } from "./sense.js";
/** Type guard for JSON {@link SenseInfo} payloads from daemon HTTP/IPC. */
export function isSenseInfo(value: unknown): value is SenseInfo {
if (!isPlainRecord(value)) return false;
return (
typeof value.name === "string" &&
typeof value.group === "string" &&
(value.throttle === null || typeof value.throttle === "number") &&
(value.timeout === null || typeof value.timeout === "number") &&
Array.isArray(value.triggers) &&
value.triggers.every((t: unknown) => typeof t === "string") &&
(value.lastSignalTimestamp === null || typeof value.lastSignalTimestamp === "number")
);
}
/** Type guard for JSON {@link WorkflowStatus} payloads from daemon HTTP/IPC. */
export function isWorkflowStatus(value: unknown): value is WorkflowStatus {
if (!isPlainRecord(value)) return false;
const cfg = value.config;
if (!isPlainRecord(cfg)) return false;
return (
typeof value.name === "string" &&
typeof value.activeThreads === "number" &&
Array.isArray(value.activeRunIds) &&
value.activeRunIds.every((id: unknown) => typeof id === "string") &&
typeof value.queuedThreads === "number" &&
typeof cfg.concurrency === "number" &&
typeof cfg.overflow === "string"
);
}
-28
View File
@@ -1,28 +0,0 @@
import type { HealthInfo, WorkflowStatus } from "./daemon-ipc-protocol.js";
import type { SenseInfo } from "./sense.js";
export type DaemonTransportTriggerResult = { ok: true } | { ok: false; error: string };
export type DaemonTransportWorkflowLaunch = {
prompt: string;
maxRounds: number;
dryRun: boolean;
};
/**
* Abstraction over daemon control plane (Unix socket IPC today, HTTP in Phase 2).
* Implementations live in CLI / tools; the daemon kernel uses shared handler logic.
*/
export type DaemonTransport = {
health(): Promise<HealthInfo>;
listSenses(): Promise<SenseInfo[]>;
listWorkflows(): Promise<WorkflowStatus[]>;
triggerSense(name: string): Promise<DaemonTransportTriggerResult>;
/** When `launch` is null, implementations use engine defaults (empty prompt, default max rounds, dryRun false). */
triggerWorkflow(
name: string,
launch: DaemonTransportWorkflowLaunch | null,
): Promise<DaemonTransportTriggerResult>;
/** Kill a running or queued workflow thread by `runId` (same field as IPC `kill-workflow`). */
killWorkflow(runId: string): Promise<DaemonTransportTriggerResult>;
};
@@ -4,8 +4,8 @@
* one response object per line from the daemon.
*/
import { isPlainRecord } from "./is-plain-record.js";
import type { SenseInfo } from "./sense.js";
import { isPlainRecord } from "./util.js";
/** Runtime status of a registered workflow (for listing / observability). */
export type WorkflowStatus = {
@@ -100,6 +100,32 @@ export type DaemonIpcResponse =
| DaemonIpcListWorkflowsResponse
| DaemonIpcHealthResponse;
export type DaemonTransportTriggerResult = { ok: true } | { ok: false; error: string };
export type DaemonTransportWorkflowLaunch = {
prompt: string;
maxRounds: number;
dryRun: boolean;
};
/**
* Abstraction over daemon control plane (Unix socket IPC today, HTTP in Phase 2).
* Implementations live in CLI / tools; the daemon kernel uses shared handler logic.
*/
export type DaemonTransport = {
health(): Promise<HealthInfo>;
listSenses(): Promise<SenseInfo[]>;
listWorkflows(): Promise<WorkflowStatus[]>;
triggerSense(name: string): Promise<DaemonTransportTriggerResult>;
/** When `launch` is null, implementations use engine defaults (empty prompt, default max rounds, dryRun false). */
triggerWorkflow(
name: string,
launch: DaemonTransportWorkflowLaunch | null,
): Promise<DaemonTransportTriggerResult>;
/** Kill a running or queued workflow thread by `runId` (same field as IPC `kill-workflow`). */
killWorkflow(runId: string): Promise<DaemonTransportTriggerResult>;
};
function parseTriggerWorkflowFields(
req: Record<string, unknown>,
): DaemonIpcTriggerWorkflowRequest | null {
@@ -150,3 +176,33 @@ export function parseDaemonIpcRequest(line: string): DaemonIpcRequest | null {
return null;
}
}
/** Type guard for JSON {@link SenseInfo} payloads from daemon HTTP/IPC. */
export function isSenseInfo(value: unknown): value is SenseInfo {
if (!isPlainRecord(value)) return false;
return (
typeof value.name === "string" &&
typeof value.group === "string" &&
(value.throttle === null || typeof value.throttle === "number") &&
(value.timeout === null || typeof value.timeout === "number") &&
Array.isArray(value.triggers) &&
value.triggers.every((t: unknown) => typeof t === "string") &&
(value.lastSignalTimestamp === null || typeof value.lastSignalTimestamp === "number")
);
}
/** Type guard for JSON {@link WorkflowStatus} payloads from daemon HTTP/IPC. */
export function isWorkflowStatus(value: unknown): value is WorkflowStatus {
if (!isPlainRecord(value)) return false;
const cfg = value.config;
if (!isPlainRecord(cfg)) return false;
return (
typeof value.name === "string" &&
typeof value.activeThreads === "number" &&
Array.isArray(value.activeRunIds) &&
value.activeRunIds.every((id: unknown) => typeof id === "string") &&
typeof value.queuedThreads === "number" &&
typeof cfg.concurrency === "number" &&
typeof cfg.overflow === "string"
);
}
-22
View File
@@ -1,22 +0,0 @@
import type { Result } from "./result.js";
import { err, ok } from "./result.js";
const DURATION_RE = /^(\d+)([smh])$/;
const DURATION_MULTIPLIERS: Record<string, number> = {
s: 1_000,
m: 60_000,
h: 3_600_000,
};
/**
* Parse a duration string such as `5s`, `10m`, `1h` to milliseconds.
* Used by `parseNerveConfig` sense/workflow duration fields.
*/
export function parseDurationStringToMs(value: string): Result<number> {
const match = DURATION_RE.exec(value);
if (!match) {
return err(new Error(`invalid duration "${value}" (expected e.g. "5s", "10m", "1h")`));
}
return ok(Number(match[1]) * DURATION_MULTIPLIERS[match[2]]);
}
+20 -19
View File
@@ -12,13 +12,15 @@ export type {
ComputeResult,
} from "./config.js";
export type { Signal, SenseInfo } from "./sense.js";
export { labelSenseTrigger, senseTriggerLabels } from "./sense-trigger-labels.js";
export type { SenseComputeFn, SenseModule } from "./sense.js";
export { labelSenseTrigger, senseTriggerLabels } from "./sense.js";
export type {
WorkflowMessage,
RoleResult,
Role,
RoleMeta,
StartStep,
ThreadContext,
WorkflowContext,
AgentFn,
RoleStep,
@@ -27,12 +29,11 @@ export type {
WorkflowDefinition,
} from "./workflow.js";
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
export type { PromptInput, RoleSpec, WorkflowSpec } from "./workflow-spec.js";
export { parseDurationStringToMs } from "./duration.js";
export type { Schema, ExtractFn } from "./extract-layer.js";
export { ExtractError } from "./extract-layer.js";
export type { Result } from "./result.js";
export { ok, err } from "./result.js";
export { parseDurationStringToMs } from "./util.js";
export type { Schema, ExtractFn } from "./agent.js";
export { ExtractError } from "./agent.js";
export type { Result } from "./util.js";
export { ok, err } from "./util.js";
export {
nerveCommandEnv,
spawnSafe,
@@ -40,17 +41,17 @@ export {
type SpawnError,
type SpawnResult,
type SpawnSafeOptions,
} from "./spawn-safe.js";
export { parseNerveConfig } from "./parse-nerve-config.js";
export type { KnowledgeConfig } from "./knowledge-config.js";
export { parseKnowledgeYaml } from "./knowledge-config.js";
export { isPlainRecord } from "./is-plain-record.js";
export { KNOWN_AGENT_ADAPTER_IDS } from "./agent-adapter-ids.js";
} from "./util.js";
export { parseNerveConfig } from "./config.js";
export type { KnowledgeConfig } from "./config.js";
export { parseKnowledgeYaml } from "./config.js";
export { isPlainRecord } from "./util.js";
export { KNOWN_AGENT_ADAPTER_IDS } from "./agent.js";
export type { RoutedSenseOutput } from "./sense-workflow-directive.js";
export { parseWorkflowTrigger, routeSenseComputeOutput } from "./sense-workflow-directive.js";
export type { RoutedSenseOutput } from "./sense.js";
export { parseWorkflowTrigger, routeSenseComputeOutput } from "./sense.js";
export { isSenseInfo, isWorkflowStatus } from "./daemon-payload-guards.js";
export { isSenseInfo, isWorkflowStatus } from "./daemon.js";
export type {
WorkflowStatus,
HealthInfo,
@@ -68,10 +69,10 @@ export type {
DaemonIpcListWorkflowsResponse,
DaemonIpcHealthResponse,
DaemonIpcResponse,
} from "./daemon-ipc-protocol.js";
export { parseDaemonIpcRequest } from "./daemon-ipc-protocol.js";
} from "./daemon.js";
export { parseDaemonIpcRequest } from "./daemon.js";
export type {
DaemonTransport,
DaemonTransportTriggerResult,
DaemonTransportWorkflowLaunch,
} from "./daemon-transport.js";
} from "./daemon.js";
-7
View File
@@ -1,7 +0,0 @@
/**
* Narrows `unknown` to a plain JSON-style object (not null, not array).
* Use after `JSON.parse` / YAML / IPC when validating structure field-by-field.
*/
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
-64
View File
@@ -1,64 +0,0 @@
import { parse } from "yaml";
import { isPlainRecord } from "./is-plain-record.js";
import type { Result } from "./result.js";
import { err, ok } from "./result.js";
export type KnowledgeConfig = {
include: ReadonlyArray<string>;
exclude: ReadonlyArray<string>;
};
function parseStringList(field: unknown, label: string): Result<ReadonlyArray<string>> {
if (field === undefined || field === null) {
return ok([]);
}
if (!Array.isArray(field)) {
return err(new Error(`${label}: must be an array of strings`));
}
const out: string[] = [];
for (let i = 0; i < field.length; i++) {
const item = field[i];
if (typeof item !== "string" || item.length === 0) {
return err(new Error(`${label}[${String(i)}]: must be a non-empty string`));
}
out.push(item);
}
return ok(out);
}
/**
* Parse `knowledge.yaml` at the repo root (RFC-003 Knowledge Layer).
* `include` / `exclude` entries are glob patterns resolved against the repo root.
*/
export function parseKnowledgeYaml(raw: string): Result<KnowledgeConfig> {
let parsed: unknown;
try {
parsed = parse(raw);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return err(new Error(`YAML parse error: ${message}`));
}
if (parsed === undefined || parsed === null) {
return ok({ include: [], exclude: [] });
}
if (!isPlainRecord(parsed)) {
return err(new Error("knowledge.yaml: root must be a mapping"));
}
const includeResult = parseStringList(parsed.include, "include");
if (!includeResult.ok) {
return includeResult;
}
const excludeResult = parseStringList(parsed.exclude, "exclude");
if (!excludeResult.ok) {
return excludeResult;
}
return ok({
include: includeResult.value,
exclude: excludeResult.value,
});
}
-348
View File
@@ -1,348 +0,0 @@
import { parse } from "yaml";
import {
DEFAULT_SENSE_SIGNAL_RETENTION,
type ExtractConfig,
type NerveApiConfig,
type NerveConfig,
type SenseConfig,
type WorkflowConfig,
} from "./config.js";
import { parseDurationStringToMs } from "./duration.js";
import { isPlainRecord } from "./is-plain-record.js";
import type { Result } from "./result.js";
import { err, ok } from "./result.js";
import { DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
function isValidGroupName(value: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(value);
}
function parseRetentionField(name: string, field: unknown): Result<number> {
if (field === undefined || field === null) {
return ok(DEFAULT_SENSE_SIGNAL_RETENTION);
}
if (typeof field !== "number" || !Number.isInteger(field) || field < 1) {
return err(new Error(`senses.${name}.retention: must be a positive integer`));
}
return ok(field);
}
function parseDurationField(field: unknown, label: string): Result<number | null> {
if (field === undefined || field === null) return ok(null);
if (typeof field !== "string") {
return err(
new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`),
);
}
const msResult = parseDurationStringToMs(field);
if (!msResult.ok) {
return err(new Error(`${label}: ${msResult.error.message}`));
}
return ok(msResult.value);
}
function validateSenseConfig(name: string, raw: unknown): Result<SenseConfig> {
if (!isPlainRecord(raw)) {
return err(new Error(`senses.${name}: must be an object`));
}
const obj = raw;
if (typeof obj.group !== "string" || obj.group.trim() === "") {
return err(new Error(`senses.${name}.group: required string`));
}
if (!isValidGroupName(obj.group)) {
return err(
new Error(
`senses.${name}.group: invalid name "${obj.group}" (only alphanumeric, underscore, hyphen allowed)`,
),
);
}
const throttleResult = parseDurationField(obj.throttle, `senses.${name}.throttle`);
if (!throttleResult.ok) return throttleResult;
const timeoutResult = parseDurationField(obj.timeout, `senses.${name}.timeout`);
if (!timeoutResult.ok) return timeoutResult;
const graceResult = parseDurationField(obj.grace_period, `senses.${name}.grace_period`);
if (!graceResult.ok) return graceResult;
const retentionResult = parseRetentionField(name, obj.retention);
if (!retentionResult.ok) return retentionResult;
const intervalResult = parseDurationField(obj.interval, `senses.${name}.interval`);
if (!intervalResult.ok) return intervalResult;
let on: string[] = [];
if (obj.on !== undefined && obj.on !== null) {
if (
!Array.isArray(obj.on) ||
!obj.on.every((item: unknown): item is string => typeof item === "string")
) {
return err(new Error(`senses.${name}.on: must be an array of strings`));
}
on = obj.on;
}
return ok({
group: obj.group,
throttle: throttleResult.value,
timeout: timeoutResult.value,
gracePeriod: graceResult.value,
retention: retentionResult.value,
interval: intervalResult.value,
on,
});
}
function parseEngineMaxRounds(obj: Record<string, unknown>): Result<number> {
if (obj.max_rounds === undefined || obj.max_rounds === null) {
return ok(DEFAULT_ENGINE_MAX_ROUNDS);
}
if (
typeof obj.max_rounds !== "number" ||
!Number.isInteger(obj.max_rounds) ||
obj.max_rounds < 1
) {
return err(new Error("max_rounds: must be a positive integer"));
}
return ok(obj.max_rounds);
}
function validateWorkflowConfig(name: string, raw: unknown): Result<WorkflowConfig> {
if (!isPlainRecord(raw)) {
return err(new Error(`workflows.${name}: must be an object`));
}
const obj = raw;
if (
typeof obj.concurrency !== "number" ||
!Number.isInteger(obj.concurrency) ||
obj.concurrency < 1
) {
return err(new Error(`workflows.${name}.concurrency: must be a positive integer`));
}
if (obj.overflow !== "drop" && obj.overflow !== "queue") {
return err(new Error(`workflows.${name}.overflow: must be "drop" or "queue"`));
}
if (obj.overflow === "drop") {
if (obj.max_queue !== undefined && obj.max_queue !== null) {
return err(new Error(`workflows.${name}: max_queue is not allowed with overflow "drop"`));
}
return ok({
concurrency: obj.concurrency,
overflow: "drop" as const,
});
}
// overflow: "queue"
let maxQueue = 100; // default
if (obj.max_queue !== undefined && obj.max_queue !== null) {
if (
typeof obj.max_queue !== "number" ||
!Number.isInteger(obj.max_queue) ||
obj.max_queue < 1
) {
return err(new Error(`workflows.${name}.max_queue: must be a positive integer`));
}
maxQueue = obj.max_queue;
}
return ok({
concurrency: obj.concurrency,
overflow: "queue" as const,
maxQueue,
});
}
function parseSenses(
obj: Record<string, unknown>,
): Result<{ senses: Record<string, SenseConfig> }> {
if (!isPlainRecord(obj.senses)) {
return err(new Error("senses: required object"));
}
const sensesRaw = obj.senses;
const senses: Record<string, SenseConfig> = {};
for (const [name, senseRaw] of Object.entries(sensesRaw)) {
const result = validateSenseConfig(name, senseRaw);
if (!result.ok) return result;
senses[name] = result.value;
}
return ok({ senses });
}
const DEFAULT_API_BIND_HOST = "127.0.0.1";
/** Hosts that may bind the HTTP API without `api.token` (loopback-only). */
function isLoopbackOnlyApiHost(host: string): boolean {
const h = host.trim();
return h === "127.0.0.1" || h.toLowerCase() === "localhost";
}
function parseApiTokenField(api: Record<string, unknown>): Result<string | null> {
if (api.token === undefined || api.token === null) {
return ok(null);
}
if (typeof api.token !== "string") {
return err(new Error("api.token: must be a string when provided"));
}
if (api.token.length === 0) {
return err(new Error("api.token: must not be empty when provided"));
}
return ok(api.token);
}
function parseApiHostField(api: Record<string, unknown>): Result<string> {
if (api.host === undefined || api.host === null) {
return ok(DEFAULT_API_BIND_HOST);
}
if (typeof api.host !== "string") {
return err(new Error("api.host: must be a string when provided"));
}
if (api.host.length === 0) {
return err(new Error("api.host: must not be empty when provided"));
}
return ok(api.host);
}
function parseApiConfig(obj: Record<string, unknown>): Result<NerveApiConfig> {
if (obj.api === undefined || obj.api === null) {
return ok({ port: null, token: null, host: DEFAULT_API_BIND_HOST });
}
if (!isPlainRecord(obj.api)) {
return err(new Error("api: must be an object if provided"));
}
const api = obj.api;
if (api.port === undefined || api.port === null) {
return ok({ port: null, token: null, host: DEFAULT_API_BIND_HOST });
}
if (
typeof api.port !== "number" ||
!Number.isInteger(api.port) ||
api.port < 1 ||
api.port > 65_535
) {
return err(new Error("api.port: must be an integer between 1 and 65535 if provided"));
}
const tokenResult = parseApiTokenField(api);
if (!tokenResult.ok) return tokenResult;
const hostResult = parseApiHostField(api);
if (!hostResult.ok) return hostResult;
if (!isLoopbackOnlyApiHost(hostResult.value) && tokenResult.value === null) {
return err(
new Error("api.host binds to non-loopback address, api.token is required for security"),
);
}
return ok({ port: api.port, token: tokenResult.value, host: hostResult.value });
}
function parseWorkflows(obj: Record<string, unknown>): Result<Record<string, WorkflowConfig>> {
if (obj.workflows === undefined || obj.workflows === null) return ok({});
if (!isPlainRecord(obj.workflows)) {
return err(new Error("workflows: must be an object if provided"));
}
const workflowsRaw = obj.workflows;
const workflows: Record<string, WorkflowConfig> = {};
for (const [name, wfRaw] of Object.entries(workflowsRaw)) {
const result = validateWorkflowConfig(name, wfRaw);
if (!result.ok) return result;
workflows[name] = result.value;
}
return ok(workflows);
}
function parseExtract(obj: Record<string, unknown>): Result<ExtractConfig | null> {
if (obj.extract === undefined || obj.extract === null) {
return ok(null);
}
if (!isPlainRecord(obj.extract)) {
return err(new Error("extract: must be an object if provided"));
}
const ext = obj.extract;
if (typeof ext.provider !== "string" || ext.provider.trim() === "") {
return err(new Error("extract.provider: required non-empty string"));
}
if (typeof ext.model !== "string" || ext.model.trim() === "") {
return err(new Error("extract.model: required non-empty string"));
}
return ok({ provider: ext.provider, model: ext.model });
}
export function parseNerveConfig(raw: string): Result<NerveConfig> {
let parsed: unknown;
try {
parsed = parse(raw);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return err(new Error(`YAML parse error: ${message}`));
}
if (!isPlainRecord(parsed)) {
return err(new Error("Config must be a YAML object"));
}
const obj = parsed;
const sensesResult = parseSenses(obj);
if (!sensesResult.ok) return sensesResult;
const { senses } = sensesResult.value;
// Legacy top-level `reflexes` is rejected; each sense carries `interval` / `on` for the sense scheduler.
if (Object.hasOwn(obj, "reflexes")) {
return err(
new Error(
"reflexes: top-level key is no longer supported; set `interval` and `on` on each sense under `senses.<name>`",
),
);
}
const workflowsResult = parseWorkflows(obj);
if (!workflowsResult.ok) return workflowsResult;
const maxRoundsResult = parseEngineMaxRounds(obj);
if (!maxRoundsResult.ok) return maxRoundsResult;
const apiResult = parseApiConfig(obj);
if (!apiResult.ok) return apiResult;
if (Object.hasOwn(obj, "agents")) {
return err(
new Error(
"agents: key is no longer supported — declare adapters on WorkflowSpec roles (RFC-003)",
),
);
}
const extractResult = parseExtract(obj);
if (!extractResult.ok) return extractResult;
return ok({
maxRounds: maxRoundsResult.value,
senses,
workflows: workflowsResult.value,
api: apiResult.value,
extract: extractResult.value,
});
}
-9
View File
@@ -1,9 +0,0 @@
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
export function err<E = Error>(error: E): Result<never, E> {
return { ok: false, error };
}
-40
View File
@@ -1,40 +0,0 @@
import type { SenseConfig } from "./config.js";
function formatIntervalMs(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
/** Human-readable label for a sense schedule (`interval` and/or `on`). */
export function labelSenseTrigger(slice: Pick<SenseConfig, "interval" | "on">): string {
const parts: string[] = [];
if (slice.interval !== null) {
parts.push(`every ${formatIntervalMs(slice.interval)}`);
}
if (slice.on.length > 0) {
parts.push(`on: ${slice.on.join(", ")}`);
}
if (parts.length === 0) {
return "trigger (no interval or on)";
}
return parts.join(" · ");
}
/**
* Human-readable trigger labels for a sense from its `SenseConfig.interval` / `.on`.
* Returns an empty array when the sense is missing or has no schedule.
*/
export function senseTriggerLabels(
senseName: string,
senses: Record<string, SenseConfig>,
): string[] {
const sc = senses[senseName];
if (sc === undefined) return [];
if (sc.interval === null && sc.on.length === 0) return [];
return [labelSenseTrigger({ interval: sc.interval, on: sc.on })];
}
@@ -1,56 +0,0 @@
import type { WorkflowTrigger } from "./config.js";
import { isPlainRecord } from "./is-plain-record.js";
import type { Result } from "./result.js";
import { err, ok } from "./result.js";
/** Normalized non-null compute output for the kernel (unknown signal payload). */
export type RoutedSenseOutput = {
signal: unknown;
workflow: WorkflowTrigger | null;
};
/**
* Validates a structured workflow trigger object from Sense compute or IPC.
*/
export function parseWorkflowTrigger(value: unknown): Result<WorkflowTrigger> {
if (!isPlainRecord(value)) {
return err(new Error("workflow trigger must be a plain object"));
}
const nameRaw = value.name;
if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) {
return err(new Error('workflow trigger: "name" must be a non-empty string'));
}
const maxRounds = value.maxRounds;
if (typeof maxRounds !== "number" || !Number.isInteger(maxRounds) || maxRounds < 1) {
return err(new Error('workflow trigger: "maxRounds" must be an integer >= 1'));
}
const prompt = value.prompt;
if (typeof prompt !== "string") {
return err(new Error('workflow trigger: "prompt" must be a string'));
}
const dryRun = value.dryRun;
if (typeof dryRun !== "boolean") {
return err(new Error('workflow trigger: "dryRun" must be a boolean'));
}
return ok({ name: nameRaw.trim(), maxRounds, prompt, dryRun });
}
/**
* Interprets a Sense compute non-null return value for the engine.
* - Explicit `{ signal, workflow }` (workflow may be null): validates `workflow` when non-null.
* - Any other value: treated as `{ signal: payload, workflow: null }` (shorthand).
*/
export function routeSenseComputeOutput(payload: unknown): Result<RoutedSenseOutput> {
if (isPlainRecord(payload) && Object.hasOwn(payload, "signal")) {
const wfRaw = Object.hasOwn(payload, "workflow") ? payload.workflow : null;
if (wfRaw === null) {
return ok({ signal: payload.signal, workflow: null });
}
const parsed = parseWorkflowTrigger(wfRaw);
if (!parsed.ok) {
return ok({ signal: payload.signal, workflow: null });
}
return ok({ signal: payload.signal, workflow: parsed.value });
}
return ok({ signal: payload, workflow: null });
}
+116
View File
@@ -1,3 +1,8 @@
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
import type { ComputeResult, SenseConfig, WorkflowTrigger } from "./config.js";
import { type Result, err, isPlainRecord, ok } from "./util.js";
export type Signal = {
id: number;
senseId: string;
@@ -15,3 +20,114 @@ export type SenseInfo = {
triggers: string[];
lastSignalTimestamp: number | null;
};
/**
* The function signature every sense `src/index.ts` must export as a named
* `compute` export.
*
* Pure: no DB, no peers.
* Return `null` to stay silent, or `{ signal, workflow }` to emit a Signal
* (and optionally trigger a Workflow).
* The runtime handles persistence via `db.insert(table).values(result.signal)`.
*/
export type SenseComputeFn<T = unknown> = () => Promise<ComputeResult<T>>;
/**
* The full shape a sense module (`src/index.ts`) must export.
* `compute` provides the data; `table` tells the runtime where to persist it.
*/
export type SenseModule<T = unknown> = {
compute: SenseComputeFn<T>;
table: SQLiteTable;
};
/** Normalized non-null compute output for the kernel (unknown signal payload). */
export type RoutedSenseOutput = {
signal: unknown;
workflow: WorkflowTrigger | null;
};
function formatIntervalMs(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
/** Human-readable label for a sense schedule (`interval` and/or `on`). */
export function labelSenseTrigger(slice: Pick<SenseConfig, "interval" | "on">): string {
const parts: string[] = [];
if (slice.interval !== null) {
parts.push(`every ${formatIntervalMs(slice.interval)}`);
}
if (slice.on.length > 0) {
parts.push(`on: ${slice.on.join(", ")}`);
}
if (parts.length === 0) {
return "trigger (no interval or on)";
}
return parts.join(" · ");
}
/**
* Human-readable trigger labels for a sense from its `SenseConfig.interval` / `.on`.
* Returns an empty array when the sense is missing or has no schedule.
*/
export function senseTriggerLabels(
senseName: string,
senses: Record<string, SenseConfig>,
): string[] {
const sc = senses[senseName];
if (sc === undefined) return [];
if (sc.interval === null && sc.on.length === 0) return [];
return [labelSenseTrigger({ interval: sc.interval, on: sc.on })];
}
/**
* Validates a structured workflow trigger object from Sense compute or IPC.
*/
export function parseWorkflowTrigger(value: unknown): Result<WorkflowTrigger> {
if (!isPlainRecord(value)) {
return err(new Error("workflow trigger must be a plain object"));
}
const nameRaw = value.name;
if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) {
return err(new Error('workflow trigger: "name" must be a non-empty string'));
}
const maxRounds = value.maxRounds;
if (typeof maxRounds !== "number" || !Number.isInteger(maxRounds) || maxRounds < 1) {
return err(new Error('workflow trigger: "maxRounds" must be an integer >= 1'));
}
const prompt = value.prompt;
if (typeof prompt !== "string") {
return err(new Error('workflow trigger: "prompt" must be a string'));
}
const dryRun = value.dryRun;
if (typeof dryRun !== "boolean") {
return err(new Error('workflow trigger: "dryRun" must be a boolean'));
}
return ok({ name: nameRaw.trim(), maxRounds, prompt, dryRun });
}
/**
* Interprets a Sense compute non-null return value for the engine.
* - Explicit `{ signal, workflow }` (workflow may be null): validates `workflow` when non-null.
* - Any other value: treated as `{ signal: payload, workflow: null }` (shorthand).
*/
export function routeSenseComputeOutput(payload: unknown): Result<RoutedSenseOutput> {
if (isPlainRecord(payload) && Object.hasOwn(payload, "signal")) {
const wfRaw = Object.hasOwn(payload, "workflow") ? payload.workflow : null;
if (wfRaw === null) {
return ok({ signal: payload.signal, workflow: null });
}
const parsed = parseWorkflowTrigger(wfRaw);
if (!parsed.ok) {
return ok({ signal: payload.signal, workflow: null });
}
return ok({ signal: payload.signal, workflow: parsed.value });
}
return ok({ signal: payload, workflow: null });
}
@@ -1,7 +1,8 @@
import { spawn } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";
import { type Result, err, ok } from "./result.js";
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
/** Compatible with `process.env` for `child_process.spawn`. */
export type SpawnEnv = Record<string, string | undefined>;
@@ -40,6 +41,42 @@ type SpawnSafeOptionsInput = SpawnSafeOptions | Omit<SpawnSafeOptions, "dryRun">
const DEFAULT_TIMEOUT_MS = 300_000;
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
export function err<E = Error>(error: E): Result<never, E> {
return { ok: false, error };
}
/**
* Narrows `unknown` to a plain JSON-style object (not null, not array).
* Use after `JSON.parse` / YAML / IPC when validating structure field-by-field.
*/
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const DURATION_RE = /^(\d+)([smh])$/;
const DURATION_MULTIPLIERS: Record<string, number> = {
s: 1_000,
m: 60_000,
h: 3_600_000,
};
/**
* Parse a duration string such as `5s`, `10m`, `1h` to milliseconds.
* Used by `parseNerveConfig` sense/workflow duration fields.
*/
export function parseDurationStringToMs(value: string): Result<number> {
const match = DURATION_RE.exec(value);
if (!match) {
return err(new Error(`invalid duration "${value}" (expected e.g. "5s", "10m", "1h")`));
}
return ok(Number(match[1]) * DURATION_MULTIPLIERS[match[2]]);
}
/**
* PATH and PNPM_HOME for running `pnpm` and `nerve` from workflow roles.
* Uses the pnpm store home only (no npm user bin); binaries must resolve via PATH.
-24
View File
@@ -1,24 +0,0 @@
import type { Schema } from "./extract-layer.js";
import type { AgentFn, Moderator, RoleMeta, StartStep, WorkflowMessage } from "./workflow.js";
/** Static string or async prompt built from thread context (RFC-003 dynamic prompts). */
export type PromptInput =
| string
| ((start: StartStep, messages: WorkflowMessage[]) => Promise<string>);
/**
* Authoring-time role: adapter function, prompt, extract schema (RFC-003).
* Compiles to runtime `Role<Meta>` via `compileWorkflowSpec`.
*/
export type RoleSpec<Meta extends Record<string, unknown>> = {
adapter: AgentFn;
prompt: PromptInput;
meta: Schema<Meta>;
};
/** User-facing workflow authoring shape; compiles to `WorkflowDefinition`. */
export type WorkflowSpec<M extends RoleMeta> = {
name: string;
roles: { [K in keyof M]: RoleSpec<M[K]> };
moderator: Moderator<M>;
};
+28 -32
View File
@@ -21,39 +21,41 @@ export type WorkflowMessage = {
/** The typed output of a Role execution. */
export type RoleResult<Meta> = { content: string; meta: Meta };
/**
* A Role is a pure async function: receives the engine start frame plus prior
* role messages only (the start frame is not included in `messages`).
* Returns typed content + meta. Implementation can be an agent, LLM call,
* script, HTTP request, etc.
*/
export type Role<Meta> = (
start: StartStep,
messages: WorkflowMessage[],
) => Promise<RoleResult<Meta>>;
/** Maps role names to their meta types — the single generic that drives all inference. */
export type RoleMeta = Record<string, Record<string, unknown>>;
/** Engine start frame: prompt, max rounds cap, dry-run flag, and timestamps for the thread. */
/** Engine start frame: initial prompt, max rounds cap, and thread identity. */
export type StartStep = {
role: START;
content: string;
/** Thread identity (same as workflow `runId`); for role prompts and CLI `nerve thread` context. */
meta: { maxRounds: number; dryRun: boolean; threadId: string };
meta: { maxRounds: number; threadId: string };
timestamp: number;
};
/** Thread context passed to agent adapters (RFC-003): conversation frame, repo root, cancellation. */
export type WorkflowContext = {
/**
* Thread-scoped context for roles, moderator, and agent adapters (RFC-005).
* `workdir` and `signal` are adapter/engine config — not part of this shape.
*/
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
threadId: string;
start: StartStep;
messages: WorkflowMessage[];
workdir: string;
signal: AbortSignal;
steps: RoleStep<M>[];
};
/**
* @deprecated Use {@link ThreadContext}.
*/
export type WorkflowContext = ThreadContext;
/**
* A Role receives the full thread context (start frame + prior role steps) and returns
* typed content + meta. Implementation can be an agent, LLM call, script, HTTP request, etc.
*/
export type Role<Meta> = (ctx: ThreadContext) => Promise<RoleResult<Meta>>;
/** Unified agent invocation — raw string output; structured meta uses the extract layer. */
export type AgentFn = (prompt: string, context: WorkflowContext) => Promise<string>;
export type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
/** A discriminated union of role steps after each execution, aligned with `StartStep` shape. */
export type RoleStep<M extends RoleMeta> = {
@@ -61,23 +63,17 @@ export type RoleStep<M extends RoleMeta> = {
}[keyof M & string];
/**
* Moderator input: the complete workflow history.
* Contains the start frame and all role steps so far.
* On initial call, `steps` is empty — moderator can check `steps.length === 0`.
* Round count is `steps.length`; maxRounds is in `start.meta.maxRounds`.
* @deprecated Use {@link ThreadContext}.
*/
export type ModeratorContext<M extends RoleMeta> = {
start: StartStep;
steps: RoleStep<M>[];
};
export type ModeratorContext<M extends RoleMeta> = ThreadContext<M>;
/**
* The moderator — a pure routing function. Receives the full workflow context
* (start frame + all prior steps). Returns the next role name or END.
* The moderator — a pure routing function. Receives the full thread context
* (start frame + all prior steps). On initial call, `steps` is empty.
* Round count is `steps.length`; maxRounds is in `start.meta.maxRounds`.
* Returns the next role name or END.
*/
export type Moderator<M extends RoleMeta> = (
context: ModeratorContext<M>,
) => (keyof M & string) | END;
export type Moderator<M extends RoleMeta> = (ctx: ThreadContext<M>) => (keyof M & string) | END;
/** The complete definition of a workflow, as authored by users. */
export type WorkflowDefinition<M extends RoleMeta> = {
@@ -1,188 +0,0 @@
import { describe, expect, it } from "vitest";
import type {
AgentFn,
ModeratorContext,
RoleMeta,
Schema,
StartStep,
WorkflowContext,
WorkflowDefinition,
WorkflowMessage,
WorkflowSpec,
} from "@uncaged/nerve-core";
import { END, START } from "@uncaged/nerve-core";
import { compileWorkflowSpec } from "../compile-workflow-spec.js";
type DemoMeta = { n: number };
function echoAdapter(): AgentFn {
return async (prompt: string, _ctx: WorkflowContext) => prompt;
}
function makeStart(threadId = "t1"): StartStep {
return {
role: START,
content: "",
meta: { maxRounds: 10, dryRun: false, threadId },
timestamp: Date.now(),
};
}
function makeContext(start: StartStep, messages: WorkflowMessage[]): WorkflowContext {
return {
start,
messages,
workdir: "/tmp/repo",
signal: new AbortController().signal,
};
}
describe("compileWorkflowSpec", () => {
it("compiles WorkflowSpec to WorkflowDefinition shape", () => {
const witness: DemoMeta | null = null;
const schema: Schema<DemoMeta> = { witness };
const spec: WorkflowSpec<{ main: DemoMeta }> = {
name: "demo",
roles: {
main: {
adapter: echoAdapter(),
prompt: "hello",
meta: schema,
},
},
moderator: (_ctx: ModeratorContext<{ main: DemoMeta }>) => END,
};
const def = compileWorkflowSpec(spec, {
extractFn: async <T>(raw: string, _s: Schema<T>) => ({ n: raw.length }) as T,
createContext: makeContext,
});
expect(def.name).toBe("demo");
expect(typeof def.roles.main).toBe("function");
expect(def.moderator).toBe(spec.moderator);
});
it("runs AgentFn then ExtractFn in order", async () => {
const witness: DemoMeta | null = null;
const schema: Schema<DemoMeta> = { witness };
const order: string[] = [];
const baseEcho = echoAdapter();
const spyAgent: AgentFn = async (prompt, ctx) => {
order.push("agent");
return baseEcho(prompt, ctx);
};
const spec: WorkflowSpec<{ main: DemoMeta }> = {
name: "order-test",
roles: {
main: {
adapter: spyAgent,
prompt: "ping",
meta: schema,
},
},
moderator: () => END,
};
const def = compileWorkflowSpec(spec, {
extractFn: async <T>(raw: string, _sch: Schema<T>) => {
order.push("extract");
return { n: raw.length } as T;
},
createContext: makeContext,
});
const start = makeStart();
await def.roles.main(start, []);
expect(order).toEqual(["agent", "extract"]);
});
it("passes WorkflowContext from createContext to AgentFn (adapter owns timeout)", async () => {
const witness: DemoMeta | null = null;
const schema: Schema<DemoMeta> = { witness };
const seenCtx: WorkflowContext[] = [];
const adapter: AgentFn = async (_prompt, ctx) => {
seenCtx.push(ctx);
return "x";
};
const spec: WorkflowSpec<{ main: DemoMeta }> = {
name: "ctx",
roles: {
main: {
adapter,
prompt: "x",
meta: schema,
},
},
moderator: () => END,
};
await compileWorkflowSpec(spec, {
extractFn: async <T>(_raw: string, _s: Schema<T>) => ({ n: 0 }) as T,
createContext: makeContext,
}).roles.main(makeStart(), []);
expect(seenCtx).toHaveLength(1);
expect(seenCtx[0].workdir).toBe("/tmp/repo");
});
it("resolves dynamic prompt functions before AgentFn", async () => {
const witness: DemoMeta | null = null;
const schema: Schema<DemoMeta> = { witness };
const spec: WorkflowSpec<{ main: DemoMeta }> = {
name: "dyn",
roles: {
main: {
adapter: echoAdapter(),
prompt: async (start, messages) => `tid=${start.meta.threadId} n=${messages.length}`,
meta: schema,
},
},
moderator: () => END,
};
const def = compileWorkflowSpec(spec, {
extractFn: async <T>(raw: string, _s: Schema<T>) => ({ n: raw.length }) as T,
createContext: makeContext,
});
const start = makeStart("thread-x");
const msgs: WorkflowMessage[] = [{ role: "a", content: "m", meta: {}, timestamp: 1 }];
const out = await def.roles.main(start, msgs);
expect(out.content).toBe("tid=thread-x n=1");
expect(out.meta.n).toBe(out.content.length);
});
});
describe("backward compatibility", () => {
it("hand-written Role-based WorkflowDefinition remains valid", async () => {
type M = RoleMeta & { legacy: { id: string } };
const manual: WorkflowDefinition<M> = {
name: "legacy",
roles: {
legacy: async (_start, _messages) => ({
content: "hi",
meta: { id: "a" },
}),
},
moderator: (_ctx: ModeratorContext<M>) => END,
};
const start = makeStart();
const out = await manual.roles.legacy(start, []);
expect(out.content).toBe("hi");
expect(out.meta.id).toBe("a");
});
});
@@ -7,10 +7,9 @@ import { drizzle } from "drizzle-orm/node-sqlite";
import { integer, real, sqliteTable } from "drizzle-orm/sqlite-core";
import { describe, expect, it } from "vitest";
import { createBlobStore } from "@uncaged/nerve-store";
import { parseParentMessage } from "../ipc.js";
import { executeCompute, openPeerDb, openSenseDb, runMigrations } from "../sense-runtime.js";
import type { ComputeFn, DrizzleDB, PeerMap, SenseRuntime } from "../sense-runtime.js";
import { executeCompute, openSenseDb, runMigrations } from "../sense-runtime.js";
import type { DrizzleDB, SenseRuntime } from "../sense-runtime.js";
// ---------------------------------------------------------------------------
// Helpers
@@ -151,76 +150,50 @@ describe("openSenseDb", () => {
});
});
// ---------------------------------------------------------------------------
// openPeerDb
// ---------------------------------------------------------------------------
describe("openPeerDb", () => {
it("opens an existing db in read-only mode", () => {
// Create a writable db first
const dbPath = makeTempDbPath();
const sqlite = new DatabaseSync(dbPath);
sqlite.exec(INIT_SQL);
sqlite.prepare("INSERT INTO samples (ts, value) VALUES (1, 42.0)").run();
sqlite.close();
const result = openPeerDb(dbPath);
expect(result.ok).toBe(true);
if (!result.ok) return;
// Should be able to read
const peerDb = result.value;
const rows = peerDb.select().from(samples).all();
expect(rows).toHaveLength(1);
expect(rows[0].value).toBe(42.0);
});
it("returns err when db path does not exist", () => {
const result = openPeerDb("/nonexistent/path/to/peer.db");
expect(result.ok).toBe(false);
});
});
// ---------------------------------------------------------------------------
// executeCompute
// ---------------------------------------------------------------------------
describe("executeCompute", () => {
function makeRuntime(computeFn: ComputeFn): {
runtime: SenseRuntime;
sqlite: DatabaseSync;
} {
const sqlite = new DatabaseSync(":memory:");
sqlite.exec(INIT_SQL);
const db = drizzle({ client: sqlite }) as DrizzleDB;
function makeRuntime(
computeFn: SenseRuntime["compute"],
sqlite?: DatabaseSync,
): { runtime: SenseRuntime; sqlite: DatabaseSync } {
const db_sqlite = sqlite ?? new DatabaseSync(":memory:");
if (!sqlite) db_sqlite.exec(INIT_SQL);
const db = drizzle({ client: db_sqlite }) as DrizzleDB;
return {
runtime: { name: "test-sense", db, compute: computeFn, persistSignal: () => {} },
sqlite,
runtime: {
name: "test-sense",
db,
compute: computeFn,
table: samples,
persistSignal: () => {},
},
sqlite: db_sqlite,
};
}
const emptyPeers: PeerMap = {};
it("returns non-null and inserts into table when compute returns data", async () => {
const { runtime, sqlite } = makeRuntime(async () => ({
signal: { ts: 1000, value: 0.5 },
workflow: null,
}));
it("returns the compute result when compute returns a non-null value", async () => {
const { runtime, sqlite } = makeRuntime(async (db) => {
await db.insert(samples).values({ ts: Date.now(), value: 0.5 });
return { signal: 0.5, workflow: null };
});
const result = await executeCompute(runtime, emptyPeers);
const result = await executeCompute(runtime);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toEqual({ signal: 0.5, workflow: null });
expect(result.value).toEqual({ signal: { ts: 1000, value: 0.5 }, workflow: null });
const rows = sqlite.prepare("SELECT * FROM samples").all();
expect(rows).toHaveLength(1);
sqlite.close();
});
it("returns null (no signal) when compute returns null", async () => {
it("returns null and does not insert when compute returns null", async () => {
const { runtime, sqlite } = makeRuntime(async () => null);
const result = await executeCompute(runtime, emptyPeers);
const result = await executeCompute(runtime);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toBeNull();
@@ -235,60 +208,14 @@ describe("executeCompute", () => {
throw new Error("something went wrong");
});
const result = await executeCompute(runtime, emptyPeers);
const result = await executeCompute(runtime);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toContain("something went wrong");
sqlite.close();
});
it("compute can read from peers", async () => {
// Set up a peer db with data
const peerSqlite = new DatabaseSync(":memory:");
peerSqlite.exec(INIT_SQL);
peerSqlite.prepare("INSERT INTO samples (ts, value) VALUES (100, 3.14)").run();
const peerDb = drizzle({ client: peerSqlite }) as DrizzleDB;
const peers: PeerMap = { "other-sense": peerDb };
const { runtime, sqlite } = makeRuntime(async (_db, p) => {
const rows = await p["other-sense"].select().from(samples).all();
return rows.length > 0 ? { signal: rows[0].value, workflow: null } : null;
});
const result = await executeCompute(runtime, peers);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toEqual({ signal: 3.14, workflow: null });
peerSqlite.close();
sqlite.close();
});
it("write to own db does not affect peer db (isolation)", async () => {
const peerSqlite = new DatabaseSync(":memory:");
peerSqlite.exec(INIT_SQL);
const peerDb = drizzle({ client: peerSqlite }) as DrizzleDB;
const peers: PeerMap = { "peer-sense": peerDb };
const { runtime, sqlite } = makeRuntime(async (db) => {
await db.insert(samples).values({ ts: 999, value: 9.9 });
return { signal: 9.9, workflow: null };
});
await executeCompute(runtime, peers);
const peerRows = peerSqlite.prepare("SELECT * FROM samples").all();
expect(peerRows).toHaveLength(0);
const ownRows = sqlite.prepare("SELECT * FROM samples").all();
expect(ownRows).toHaveLength(1);
peerSqlite.close();
sqlite.close();
});
it("inserts correctly into the sense db directory path", async () => {
it("inserts correctly into the sense db from openSenseDb", async () => {
const dbPath = makeTempDbPath();
const migrationsDir = makeTempMigrationsDir(INIT_SQL);
const dbResult = openSenseDb(dbPath, migrationsDir);
@@ -301,14 +228,12 @@ describe("executeCompute", () => {
const runtime: SenseRuntime = {
name: "cpu-usage",
db,
compute: async (d) => {
await d.insert(samples).values({ ts: 1000, value: 1.23 });
return { signal: 1.23, workflow: null };
},
compute: async () => ({ signal: { ts: 1000, value: 1.23 }, workflow: null }),
table: samples,
persistSignal: () => {},
};
const result = await executeCompute(runtime, {});
const result = await executeCompute(runtime);
expect(result.ok).toBe(true);
const rows = dbSqlite.prepare("SELECT * FROM samples").all() as Array<{
@@ -323,17 +248,10 @@ describe("executeCompute", () => {
it("returns err when compute exceeds timeoutMs", async () => {
const { runtime, sqlite } = makeRuntime(
(_db, _peers, options) =>
new Promise<null>((resolve, reject) => {
const t = setTimeout(() => resolve(null), 5_000);
options?.signal.addEventListener("abort", () => {
clearTimeout(t);
reject(new Error("aborted"));
});
}),
() => new Promise<null>((resolve) => setTimeout(() => resolve(null), 5_000)),
);
const result = await executeCompute(runtime, emptyPeers, 50);
const result = await executeCompute(runtime, 50);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/timed out/i);
@@ -341,37 +259,14 @@ describe("executeCompute", () => {
});
it("completes within timeout when compute is fast", async () => {
const { runtime, sqlite } = makeRuntime(async () => ({ signal: 42, workflow: null }));
const result = await executeCompute(runtime, emptyPeers, 5_000);
const { runtime, sqlite } = makeRuntime(async () => ({
signal: { ts: 1, value: 42 },
workflow: null,
}));
const result = await executeCompute(runtime, 5_000);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toEqual({ signal: 42, workflow: null });
sqlite.close();
});
it("passes AbortSignal to compute fn", async () => {
let capturedSignal: AbortSignal | undefined;
const { runtime, sqlite } = makeRuntime(async (_db, _peers, options) => {
capturedSignal = options?.signal;
return null;
});
await executeCompute(runtime, emptyPeers, 1_000);
expect(capturedSignal).toBeInstanceOf(AbortSignal);
sqlite.close();
});
it("passes BlobStore as options.blobs when blobStore argument is provided", async () => {
const blobsRoot = mkdtempSync(join(tmpdir(), "nerve-blobs-"));
const blobStore = createBlobStore(blobsRoot);
let seen: ReturnType<typeof createBlobStore> | undefined;
const { runtime, sqlite } = makeRuntime(async (_db, _peers, options) => {
seen = options?.blobs;
return null;
});
await executeCompute(runtime, emptyPeers, undefined, blobStore);
expect(seen).toBe(blobStore);
expect(result.value).toEqual({ signal: { ts: 1, value: 42 }, workflow: null });
sqlite.close();
});
});
@@ -429,19 +324,14 @@ describe("runMigrations journal", () => {
const first = runMigrations(sqlite, dir);
expect(first.ok).toBe(true);
// Insert a row so we can verify second run doesn't fail on CREATE TABLE
sqlite.exec("INSERT INTO samples (ts, value) VALUES (1, 1.0)");
// Run again — migration must NOT re-run (would fail without IF NOT EXISTS but
// the journal prevents it even for non-idempotent SQL)
const nonIdempotentSql = "CREATE TABLE samples2 (id INTEGER PRIMARY KEY)";
writeFileSync(join(dir, "0002_samples2.sql"), nonIdempotentSql);
// First time: creates samples2
const second = runMigrations(sqlite, dir);
expect(second.ok).toBe(true);
// Second time: 0002 already in journal, must not re-run
const third = runMigrations(sqlite, dir);
expect(third.ok).toBe(true);
+2 -2
View File
@@ -1,9 +1,9 @@
import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core";
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
/**
* Echo adapter (`type: "echo"`) — returns the assembled prompt unchanged.
* Used for tests and dry-run wiring before real adapters exist.
*/
export function createEchoAgent(_config: AgentConfig): AgentFn {
return async (prompt: string, _context: WorkflowContext) => prompt;
return async (_ctx: ThreadContext, prompt: string) => prompt;
}
@@ -1,59 +0,0 @@
import type {
Role,
RoleMeta,
RoleSpec,
Schema,
StartStep,
WorkflowContext,
WorkflowDefinition,
WorkflowMessage,
WorkflowSpec,
} from "@uncaged/nerve-core";
export type CompileWorkflowSpecDeps = {
/**
* Typed extraction for agent raw output (global/role merge applied before compile).
*/
extractFn: <T>(raw: string, schema: Schema<T>) => Promise<T>;
/** Builds thread context for each role invocation (workdir, cancellation, etc.). */
createContext: (start: StartStep, messages: WorkflowMessage[]) => WorkflowContext;
};
function compileRoleForSpec<Meta extends Record<string, unknown>>(
roleSpec: RoleSpec<Meta>,
deps: CompileWorkflowSpecDeps,
): Role<Meta> {
return async (start: StartStep, messages: WorkflowMessage[]) => {
const ctx = deps.createContext(start, messages);
const promptText =
typeof roleSpec.prompt === "string"
? roleSpec.prompt
: await roleSpec.prompt(start, messages);
const raw = await roleSpec.adapter(promptText, ctx);
const meta = await deps.extractFn(raw, roleSpec.meta);
return { content: raw, meta };
};
}
/**
* Turns RFC-003 `WorkflowSpec` into engine `WorkflowDefinition`: wires adapters and extract per role.
*/
export function compileWorkflowSpec<M extends RoleMeta>(
spec: WorkflowSpec<M>,
deps: CompileWorkflowSpecDeps,
): WorkflowDefinition<M> {
const roleKeys = Object.keys(spec.roles) as Array<keyof M & string>;
const roles = {} as WorkflowDefinition<M>["roles"];
for (const key of roleKeys) {
roles[key] = compileRoleForSpec(spec.roles[key], deps);
}
return {
name: spec.name,
roles,
moderator: spec.moderator,
};
}
+2 -5
View File
@@ -17,13 +17,12 @@ export type {
export type { SignalBus, SignalHandler, Unsubscribe } from "./signal-bus.js";
export type { ComputeFn, DrizzleDB, PeerMap, SenseRuntime } from "./sense-runtime.js";
export type { DrizzleDB, SenseRuntime } from "./sense-runtime.js";
export {
runMigrations,
openSenseDb,
openPeerDb,
loadComputeFn,
loadSenseModule,
executeCompute,
} from "./sense-runtime.js";
@@ -58,6 +57,4 @@ export type {
export { createWorkflowManager } from "./workflow-manager.js";
export type { WorkflowManager } from "./workflow-manager.js";
export { compileWorkflowSpec } from "./compile-workflow-spec.js";
export type { CompileWorkflowSpecDeps } from "./compile-workflow-spec.js";
export { createEchoAgent } from "./agent-adapters/echo.js";
+42 -2
View File
@@ -3,7 +3,7 @@
* Protocol per RFC §5.2: hub-and-spoke, all messages through engine.
*/
import type { Result } from "@uncaged/nerve-core";
import type { Result, WorkflowTrigger } from "@uncaged/nerve-core";
import { err, isPlainRecord, ok } from "@uncaged/nerve-core";
/** Parent → Worker: trigger one compute cycle for a sense */
@@ -72,6 +72,13 @@ export type SignalMessage = {
payload: unknown;
};
/** Worker → Parent: sense compute result includes a workflow to start */
export type SenseWorkflowTriggerMessage = {
type: "sense-workflow-trigger";
sense: string;
workflow: WorkflowTrigger;
};
/** Worker → Parent: compute threw or returned an unexpected error */
export type ErrorMessage = {
type: "error";
@@ -139,7 +146,8 @@ export type WorkerToParentMessage =
| HealthResponseMessage
| ThreadEventMessage
| WorkflowErrorMessage
| ThreadWorkflowMessageMessage;
| ThreadWorkflowMessageMessage
| SenseWorkflowTriggerMessage;
const PARENT_MSG_TYPES = new Set([
"compute",
@@ -340,6 +348,7 @@ const WORKER_MSG_TYPES = new Set([
"thread-event",
"workflow-error",
"thread-workflow-message",
"sense-workflow-trigger",
]);
function parseThreadWorkflowMessageMsg(
@@ -377,6 +386,36 @@ function parseThreadWorkflowMessageMsg(
});
}
function parseSenseWorkflowTriggerMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
if (typeof obj.sense !== "string") {
return err(new Error("Worker 'sense-workflow-trigger' message missing string 'sense' field"));
}
if (!isPlainRecord(obj.workflow)) {
return err(
new Error("Worker 'sense-workflow-trigger' message missing object 'workflow' field"),
);
}
const wf = obj.workflow;
if (typeof wf.name !== "string")
return err(new Error("Worker 'sense-workflow-trigger' workflow missing string 'name'"));
if (typeof wf.maxRounds !== "number")
return err(new Error("Worker 'sense-workflow-trigger' workflow missing number 'maxRounds'"));
if (typeof wf.prompt !== "string")
return err(new Error("Worker 'sense-workflow-trigger' workflow missing string 'prompt'"));
if (typeof wf.dryRun !== "boolean")
return err(new Error("Worker 'sense-workflow-trigger' workflow missing boolean 'dryRun'"));
return ok({
type: "sense-workflow-trigger",
sense: obj.sense,
workflow: {
name: wf.name,
maxRounds: wf.maxRounds,
prompt: wf.prompt,
dryRun: wf.dryRun,
},
});
}
/** Validate and parse an unknown IPC message received from a worker process. */
export function parseWorkerMessage(raw: unknown): Result<WorkerToParentMessage> {
if (!isPlainRecord(raw)) {
@@ -395,5 +434,6 @@ export function parseWorkerMessage(raw: unknown): Result<WorkerToParentMessage>
if (obj.type === "thread-event") return parseThreadEventMsg(obj);
if (obj.type === "workflow-error") return parseWorkflowErrorMsg(obj);
if (obj.type === "thread-workflow-message") return parseThreadWorkflowMessageMsg(obj);
if (obj.type === "sense-workflow-trigger") return parseSenseWorkflowTriggerMsg(obj);
return ok({ type: "ready" });
}
+36 -59
View File
@@ -4,43 +4,20 @@ import { DatabaseSync } from "node:sqlite";
import { drizzle } from "drizzle-orm/node-sqlite";
import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
import type { ComputeResult, Result } from "@uncaged/nerve-core";
import type { ComputeResult, Result, SenseComputeFn } from "@uncaged/nerve-core";
import { DEFAULT_SENSE_SIGNAL_RETENTION, err, isPlainRecord, ok } from "@uncaged/nerve-core";
import type { BlobStore } from "@uncaged/nerve-store";
/** A Drizzle DB instance (schema-generic) */
export type DrizzleDB = NodeSQLiteDatabase<Record<string, never>>;
/** Read-only map of peer sense name → their Drizzle DB */
export type PeerMap = Readonly<Record<string, DrizzleDB>>;
/** Options passed to a compute function */
export type ComputeOptions = {
signal: AbortSignal;
/** CAS under `data/blobs/`; injected by the sense worker when available. */
blobs?: BlobStore;
};
/**
* The shape every sense's index.ts must export.
* Engine injects `db` (read-write), `peers` (read-only), and `options`
* (`signal`, and `blobs` when running in the sense worker — RFC-001 §8 CAS).
* Returns a structured result when a signal should be emitted (and optionally a workflow),
* or null for silence.
*/
export type ComputeFn<T = unknown> = (
db: DrizzleDB,
peers: PeerMap,
options?: ComputeOptions,
) => Promise<ComputeResult<T>>;
/** All state held for one sense inside a worker */
export type SenseRuntime = {
name: string;
db: DrizzleDB;
compute: ComputeFn;
compute: SenseComputeFn;
table: SQLiteTable;
persistSignal: (payload: unknown) => void;
};
@@ -126,13 +103,13 @@ export function runMigrations(sqlite: DatabaseSync, migrationsDir: string): Resu
return ok(undefined);
}
/** Run `_signals` row prune after this many inserts (amortize DELETE cost). */
const SIGNAL_INSERTS_PER_PRUNE = 100;
/**
* Open (or create) the SQLite file at `dbPath`, run all migrations in
* `migrationsDir`, and wrap with Drizzle ORM.
*/
/** Run `_signals` row prune after this many inserts (amortize DELETE cost). */
const SIGNAL_INSERTS_PER_PRUNE = 100;
export function openSenseDb(
dbPath: string,
migrationsDir: string,
@@ -184,27 +161,12 @@ export function openSenseDb(
}
/**
* Open a peer sense DB in read-only mode (no migrations).
* Dynamically import the compute function and table from a sense's index.ts/js.
* The module must export a named `compute` function and a named `table` (SQLiteTable).
*/
export function openPeerDb(dbPath: string): Result<DrizzleDB> {
let sqlite: DatabaseSync;
try {
sqlite = new DatabaseSync(dbPath, { readOnly: true });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Failed to open peer database "${dbPath}" (readonly): ${msg}`));
}
// Same schema-agnostic Drizzle wrapper as openSenseDb.
return ok(drizzle({ client: sqlite }) as DrizzleDB);
}
/**
* Dynamically import the compute function from a sense's index.ts/js.
* The module must export a named `compute` function.
*/
export async function loadComputeFn(senseIndexPath: string): Promise<Result<ComputeFn>> {
export async function loadSenseModule(
senseIndexPath: string,
): Promise<Result<{ compute: SenseComputeFn; table: SQLiteTable }>> {
let mod: unknown;
try {
@@ -221,26 +183,34 @@ export async function loadComputeFn(senseIndexPath: string): Promise<Result<Comp
);
}
return ok(mod.compute as ComputeFn);
if (!("table" in mod) || mod.table === null || typeof mod.table !== "object") {
return err(
new Error(
`Sense module "${senseIndexPath}" must export a named "table" (drizzle SQLiteTable)`,
),
);
}
return ok({
compute: mod.compute as SenseComputeFn,
table: mod.table as SQLiteTable,
});
}
/**
* Execute a sense's compute function with an optional soft timeout.
* If timeoutMs is provided and compute takes longer, the AbortSignal is
* triggered and an error Result is returned.
* When `blobStore` is set, it is exposed as `options.blobs` (see RFC-001 §8).
* When compute returns non-null, `result.signal` is persisted to the sense's
* table via `db.insert(table).values(result.signal)` and `persistSignal` is
* called with `result.signal`. Returns the full `ComputeResult` so callers
* can inspect the `workflow` field.
*/
export async function executeCompute(
runtime: SenseRuntime,
peers: PeerMap,
timeoutMs?: number,
blobStore?: BlobStore,
): Promise<Result<ComputeResult<unknown>>> {
const controller = new AbortController();
const options: ComputeOptions =
blobStore !== undefined
? { signal: controller.signal, blobs: blobStore }
: { signal: controller.signal };
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise =
@@ -254,10 +224,17 @@ export async function executeCompute(
: null;
try {
const computePromise = runtime.compute(runtime.db, peers, options);
const computePromise = runtime.compute();
const result = timeoutPromise
? await Promise.race([computePromise, timeoutPromise])
: await computePromise;
if (result !== null) {
// Cast required: DrizzleDB is schema-agnostic; the sense module guarantees shape compatibility.
await runtime.db.insert(runtime.table).values(result.signal as Record<string, unknown>);
runtime.persistSignal(result.signal);
}
return ok(result);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
+27 -76
View File
@@ -3,14 +3,13 @@
*
* Entry point for `nerve worker sense --group <name>`.
* Receives the group name via CLI args, reads nerve.yaml, initialises one
* SenseRuntime per sense in the group, builds peer read-only connections,
* then signals ready and enters the IPC event loop.
* SenseRuntime per sense in the group, then signals ready and enters the
* IPC event loop.
*
* Layout assumptions (nerve user config at `~/.uncaged-nerve/`):
* senses/<name>/index.js ← compiled compute
* senses/<name>/migrations/ ← SQL migration files
* data/senses/<name>.db ← SQLite data file
* data/blobs/<aa>/<hashrest> ← CAS (sha256), via options.blobs in compute
* nerve.yaml ← config
*/
@@ -19,14 +18,13 @@ import "./experimental-warning-suppression.js";
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { parseNerveConfig, routeSenseComputeOutput } from "@uncaged/nerve-core";
import type { NerveConfig } from "@uncaged/nerve-core";
import { parseNerveConfig } from "@uncaged/nerve-core";
import type { NerveConfig, WorkflowTrigger } from "@uncaged/nerve-core";
import { createBlobStore } from "@uncaged/nerve-store";
import type { WorkerToParentMessage } from "./ipc.js";
import { parseParentMessage } from "./ipc.js";
import { executeCompute, loadComputeFn, openPeerDb, openSenseDb } from "./sense-runtime.js";
import type { DrizzleDB, PeerMap, SenseRuntime } from "./sense-runtime.js";
import { executeCompute, loadSenseModule, openSenseDb } from "./sense-runtime.js";
import type { SenseRuntime } from "./sense-runtime.js";
import { ignoreSessionBroadcastSignals } from "./worker-fork-support.js";
// ---------------------------------------------------------------------------
@@ -51,8 +49,12 @@ function sendError(sense: string, error: string): void {
send({ type: "error", sense, error });
}
function sendWorkflowTrigger(sense: string, workflow: WorkflowTrigger): void {
send({ type: "sense-workflow-trigger", sense, workflow });
}
// ---------------------------------------------------------------------------
// Initialisation helpers (each extracted to keep bootstrap complexity low)
// Initialisation helpers
// ---------------------------------------------------------------------------
function readConfig(nerveRoot: string): NerveConfig {
@@ -78,7 +80,7 @@ async function initSense(
nerveRoot: string,
senseName: string,
retention: number,
): Promise<{ db: DrizzleDB; runtime: SenseRuntime }> {
): Promise<SenseRuntime> {
const dbPath = join(nerveRoot, "data", "senses", `${senseName}.db`);
const migrationsDir = join(nerveRoot, "senses", senseName, "migrations");
const senseIndexPath = resolve(join(nerveRoot, "senses", senseName, "index.js"));
@@ -88,55 +90,20 @@ async function initSense(
throw new Error(`Failed to init DB for "${senseName}": ${dbResult.error.message}`);
}
const computeResult = await loadComputeFn(senseIndexPath);
if (!computeResult.ok) {
throw new Error(`Failed to load compute for "${senseName}": ${computeResult.error.message}`);
const moduleResult = await loadSenseModule(senseIndexPath);
if (!moduleResult.ok) {
throw new Error(`Failed to load module for "${senseName}": ${moduleResult.error.message}`);
}
const { db } = dbResult.value;
return {
db,
runtime: {
name: senseName,
db,
compute: computeResult.value,
persistSignal: dbResult.value.persistSignal,
},
name: senseName,
db: dbResult.value.db,
compute: moduleResult.value.compute,
table: moduleResult.value.table,
persistSignal: dbResult.value.persistSignal,
};
}
function buildPeers(
nerveRoot: string,
allSenseNames: string[],
ownDbs: Map<string, DrizzleDB>,
groupSenseNames: Set<string>,
): PeerMap {
const entries: [string, DrizzleDB][] = [];
for (const peerName of allSenseNames) {
// Exclude senses that belong to this worker's own group — they are not peers
if (groupSenseNames.has(peerName)) continue;
const own = ownDbs.get(peerName);
if (own !== undefined) {
entries.push([peerName, own]);
continue;
}
const peerDbPath = join(nerveRoot, "data", "senses", `${peerName}.db`);
const peerResult = openPeerDb(peerDbPath);
if (!peerResult.ok) {
process.stderr.write(
`[sense-worker] Warning: could not open peer DB for "${peerName}": ${peerResult.error.message}\n`,
);
continue;
}
entries.push([peerName, peerResult.value]);
}
return Object.fromEntries(entries);
}
// ---------------------------------------------------------------------------
// Grace period: hard kill after soft timeout
//
@@ -173,13 +140,11 @@ function clearGracePeriodTimer(senseName: string): void {
async function runCompute(
senseName: string,
runtime: SenseRuntime,
peers: PeerMap,
timeoutMs: number,
gracePeriodMs: number | null,
blobStore: ReturnType<typeof createBlobStore>,
): Promise<void> {
try {
const result = await executeCompute(runtime, peers, timeoutMs, blobStore);
const result = await executeCompute(runtime, timeoutMs);
if (!result.ok) {
sendError(senseName, result.error.message);
if (gracePeriodMs !== null && result.error.message.includes("timed out")) {
@@ -189,13 +154,10 @@ async function runCompute(
}
clearGracePeriodTimer(senseName);
if (result.value != null) {
const routeResult = routeSenseComputeOutput(result.value);
if (!routeResult.ok) {
sendError(senseName, routeResult.error.message);
return;
sendSignal(senseName, result.value.signal);
if (result.value.workflow !== null) {
sendWorkflowTrigger(senseName, result.value.workflow);
}
runtime.persistSignal(routeResult.value.signal);
sendSignal(senseName, result.value);
}
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
@@ -210,11 +172,9 @@ async function runCompute(
function handleMessage(
raw: unknown,
runtimes: Map<string, SenseRuntime>,
peers: PeerMap,
group: string,
senseConfigs: Map<string, { timeout: number | null; gracePeriod: number | null }>,
inFlight: Map<string, Promise<void>>,
blobStore: ReturnType<typeof createBlobStore>,
): void {
const parseResult = parseParentMessage(raw);
if (!parseResult.ok) {
@@ -245,14 +205,13 @@ function handleMessage(
return;
}
// Look up timeout/gracePeriod per-sense at compute time (RFC §5.3: these are per-sense)
const sc = senseConfigs.get(msg.sense);
const timeoutMs = sc?.timeout ?? DEFAULT_TIMEOUT_MS;
const gracePeriodMs = sc?.gracePeriod ?? null;
const previous = inFlight.get(msg.sense) ?? Promise.resolve();
const next = previous
.then(() => runCompute(msg.sense, runtime, peers, timeoutMs, gracePeriodMs, blobStore))
.then(() => runCompute(msg.sense, runtime, timeoutMs, gracePeriodMs))
.catch((e: unknown) => {
const errMsg = e instanceof Error ? e.message : String(e);
sendError(msg.sense, errMsg);
@@ -279,14 +238,12 @@ async function bootstrap(nerveRoot: string, group: string): Promise<void> {
}
const runtimes = new Map<string, SenseRuntime>();
const ownDbs = new Map<string, DrizzleDB>();
const failedSenses: string[] = [];
for (const senseName of groupSenses) {
try {
const retention = config.senses[senseName].retention;
const { db, runtime } = await initSense(nerveRoot, senseName, retention);
ownDbs.set(senseName, db);
const runtime = await initSense(nerveRoot, senseName, retention);
runtimes.set(senseName, runtime);
} catch (e: unknown) {
const eMsg = e instanceof Error ? e.message : String(e);
@@ -297,16 +254,11 @@ async function bootstrap(nerveRoot: string, group: string): Promise<void> {
}
}
// If ALL senses failed, exit with error so kernel respawns
if (runtimes.size === 0) {
process.stderr.write(`[sense-worker] All senses in group "${group}" failed to load, exiting\n`);
process.exit(1);
}
const groupSenseNames = new Set(groupSenses);
const peers = buildPeers(nerveRoot, Object.keys(config.senses), ownDbs, groupSenseNames);
// Build per-sense timeout/gracePeriod map (RFC §5.3: these are per-sense, not per-group)
const senseConfigs = new Map<string, { timeout: number | null; gracePeriod: number | null }>();
for (const senseName of groupSenses) {
const sc = config.senses[senseName];
@@ -317,12 +269,11 @@ async function bootstrap(nerveRoot: string, group: string): Promise<void> {
}
const inFlight = new Map<string, Promise<void>>();
const blobStore = createBlobStore(join(nerveRoot, "data", "blobs"));
sendReady();
process.on("message", (raw: unknown) => {
handleMessage(raw, runtimes, peers, group, senseConfigs, inFlight, blobStore);
handleMessage(raw, runtimes, group, senseConfigs, inFlight);
});
}
+1 -3
View File
@@ -127,7 +127,6 @@ function ensureThreadMessagesWithStart(
threadId: string,
fallbackPrompt: string,
fallbackMaxRounds: number,
fallbackDryRun: boolean,
): WorkflowMessage[] {
const mapped: WorkflowMessage[] = messages.map((m) => ({
role: m.role,
@@ -141,7 +140,7 @@ function ensureThreadMessagesWithStart(
const start: WorkflowMessage = {
role: START,
content: fallbackPrompt,
meta: { maxRounds: fallbackMaxRounds, dryRun: fallbackDryRun, threadId },
meta: { maxRounds: fallbackMaxRounds, threadId },
timestamp: Date.now(),
};
return [start, ...mapped];
@@ -408,7 +407,6 @@ export function createWorkflowManager(
runId,
launch.prompt,
launch.maxRounds,
launch.dryRun,
);
state.active.add(runId);
const msg: ResumeThreadMessage = {
+44 -39
View File
@@ -14,7 +14,14 @@ import "./experimental-warning-suppression.js";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import type { RoleMeta, StartStep, WorkflowDefinition, WorkflowMessage } from "@uncaged/nerve-core";
import type {
RoleMeta,
RoleStep,
StartStep,
ThreadContext,
WorkflowDefinition,
WorkflowMessage,
} from "@uncaged/nerve-core";
import { END, START, isPlainRecord } from "@uncaged/nerve-core";
import type {
@@ -87,15 +94,14 @@ function normalizeStartMeta(
threadIdFallback: string,
): StartStep["meta"] {
if (!isPlainRecord(meta)) {
return { maxRounds: maxRoundsFallback, dryRun: false, threadId: threadIdFallback };
return { maxRounds: maxRoundsFallback, threadId: threadIdFallback };
}
const maxRounds = typeof meta.maxRounds === "number" ? meta.maxRounds : maxRoundsFallback;
const dryRun = typeof meta.dryRun === "boolean" ? meta.dryRun : false;
const threadId =
typeof meta.threadId === "string" && meta.threadId.length > 0
? meta.threadId
: threadIdFallback;
return { maxRounds, dryRun, threadId };
return { maxRounds, threadId };
}
function startStepFromWorkflowMessage(
@@ -107,7 +113,7 @@ function startStepFromWorkflowMessage(
return {
role: START,
content: "",
meta: { maxRounds: maxRoundsFallback, dryRun: false, threadId: threadIdFallback },
meta: { maxRounds: maxRoundsFallback, threadId: threadIdFallback },
timestamp: Date.now(),
};
}
@@ -124,8 +130,30 @@ type ThreadMessagesState = {
start: StartStep;
/** Role outputs only; never includes the `__start__` frame. */
messages: WorkflowMessage[];
/** From IPC (`start-thread` / `resume-thread`); not part of `StartStep.meta`. */
dryRun: boolean;
};
function workflowMessagesToRoleSteps(messages: WorkflowMessage[]): RoleStep<RoleMeta>[] {
return messages.map((m) => ({
role: m.role,
meta: m.meta as Record<string, unknown>,
content: m.content,
timestamp: m.timestamp,
})) as RoleStep<RoleMeta>[];
}
function buildThreadContext(
start: StartStep,
roleMessages: WorkflowMessage[],
): ThreadContext<RoleMeta> {
return {
threadId: start.meta.threadId,
start,
steps: workflowMessagesToRoleSteps(roleMessages),
};
}
function initThreadMessages(
runId: string,
resumeMessages: WorkflowMessage[],
@@ -139,6 +167,7 @@ function initThreadMessages(
return {
start: startStepFromWorkflowMessage(first, maxRounds, runId),
messages: [...rest],
dryRun,
};
}
const prompt = freshPrompt ?? "";
@@ -146,17 +175,18 @@ function initThreadMessages(
start: {
role: START,
content: prompt,
meta: { maxRounds, dryRun, threadId: runId },
meta: { maxRounds, threadId: runId },
timestamp: Date.now(),
},
messages: [...resumeMessages],
dryRun,
};
}
const prompt = freshPrompt ?? "";
const start: StartStep = {
role: START,
content: prompt,
meta: { maxRounds, dryRun, threadId: runId },
meta: { maxRounds, threadId: runId },
timestamp: Date.now(),
};
sendWorkflowMessage(runId, {
@@ -165,14 +195,13 @@ function initThreadMessages(
meta: start.meta,
timestamp: start.timestamp,
});
return { start, messages: [] };
return { start, messages: [], dryRun };
}
async function executeRole(
def: WorkflowDefinition<RoleMeta>,
nextRole: string,
start: StartStep,
messages: WorkflowMessage[],
ctx: ThreadContext<RoleMeta>,
runId: string,
): Promise<{ content: string; meta: Record<string, unknown> } | null> {
const role = def.roles[nextRole];
@@ -183,7 +212,7 @@ async function executeRole(
let result: { content: string; meta: Record<string, unknown> };
try {
result = await role(start, messages);
result = await role(ctx);
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
sendThreadEvent(runId, "failed", { error: errMsg, exitCode: 1 });
@@ -213,37 +242,20 @@ async function runThread(
dryRun,
);
const steps: Array<{
role: string;
meta: Record<string, unknown>;
content: string;
timestamp: number;
}> = [];
// Rebuild steps from any resumed messages
for (const msg of roleMessages) {
steps.push({
role: msg.role,
meta: msg.meta as Record<string, unknown>,
content: msg.content,
timestamp: msg.timestamp,
});
}
if (killFlag.value) {
sendThreadEvent(runId, "killed", { exitCode: 137 });
return;
}
let nextRole = def.moderator({ start, steps });
let nextRole = def.moderator(buildThreadContext(start, roleMessages));
if (nextRole === END) {
sendThreadEvent(runId, "completed", { exitCode: 0 });
return;
}
while (steps.length < maxRounds) {
const result = await executeRole(def, nextRole, start, roleMessages, runId);
while (roleMessages.length < maxRounds) {
const result = await executeRole(def, nextRole, buildThreadContext(start, roleMessages), runId);
if (killFlag.value) {
sendThreadEvent(runId, "killed", { exitCode: 137 });
@@ -261,14 +273,7 @@ async function runThread(
roleMessages.push(message);
sendWorkflowMessage(runId, message);
steps.push({
role: nextRole,
meta: result.meta,
content: result.content,
timestamp: message.timestamp,
});
nextRole = def.moderator({ start, steps });
nextRole = def.moderator(buildThreadContext(start, roleMessages));
if (nextRole === END) {
sendThreadEvent(runId, "completed", { exitCode: 0 });
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@uncaged/nerve-role-committer",
"version": "0.5.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"publishConfig": {
"access": "public"
},
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*",
"@uncaged/nerve-workflow-utils": "workspace:*",
"zod": "^4.3.6"
},
"devDependencies": {
"@rslib/core": "^0.21.3",
"typescript": "^5.8.3",
"vitest": "^4.1.5"
}
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "@rslib/core";
export default defineConfig({
lib: [
{
format: "esm",
dts: true,
},
],
source: {
entry: {
index: "src/index.ts",
},
},
output: {
target: "node",
cleanDistPath: true,
},
});
+59
View File
@@ -0,0 +1,59 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole, decorateRole, onFail, withDryRun } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
export const committerMetaSchema = z.object({
committed: z
.boolean()
.describe("true if branch created, changes committed, and pushed successfully"),
});
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
function committerPrompt(threadId: string): string {
return `You are the committer agent. The coder finished with a passing build; your job is to branch, commit, and push.
1. Read the workflow thread: \`nerve thread show ${threadId}\` — understand what was planned, coded, and reviewed.
2. Run \`git status\`. If nothing to commit, set committed=false.
3. Create a feature branch: infer a good \`fix/<slug>\` or \`feat/<slug>\` name from the thread context.
4. \`git add -A\`
5. Write a conventional commit message based on the thread context.
6. \`git commit -m "<message>"\` — do NOT pass \`--author\`, use repo git config.
7. \`git push -u origin <branch>\`
**committed=true** only if branch was created, commit succeeded, and **push** succeeded.
End your reply with a JSON line:
\`\`\`json
{ "committed": true }
\`\`\`
or
\`\`\`json
{ "committed": false }
\`\`\``;
}
/**
* Creates a committer role that branches, commits, and pushes changes.
* The agent reads the workflow thread to infer branch name, scope, and commit message.
*/
export function createCommitterRole(
adapter: AgentFn,
extract: LlmExtractorConfig,
): Role<CommitterMeta> {
const inner = createRole(
adapter,
async (ctx: ThreadContext) => committerPrompt(ctx.threadId),
committerMetaSchema,
extract,
);
return decorateRole(inner, [
withDryRun({
label: "committer",
meta: { committed: true } as CommitterMeta,
dryRun: extract.dryRun === true,
}),
onFail({ label: "committer", meta: { committed: false } as CommitterMeta }),
]) as Role<CommitterMeta>;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": false
},
"include": ["src"]
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@uncaged/nerve-role-reviewer",
"version": "0.5.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"publishConfig": {
"access": "public"
},
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*",
"@uncaged/nerve-workflow-utils": "workspace:*",
"zod": "^4.3.6"
},
"devDependencies": {
"@rslib/core": "^0.21.3",
"typescript": "^5.8.3",
"vitest": "^4.1.5"
}
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "@rslib/core";
export default defineConfig({
lib: [
{
format: "esm",
dts: true,
},
],
source: {
entry: {
index: "src/index.ts",
},
},
output: {
target: "node",
cleanDistPath: true,
},
});
+2
View File
@@ -0,0 +1,2 @@
export { createReviewerRole, reviewerMetaSchema } from "./reviewer.js";
export type { ReviewerMeta, ReviewerConfig } from "./reviewer.js";
+91
View File
@@ -0,0 +1,91 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
export const reviewerMetaSchema = z.object({
approved: z.boolean().describe("true if the diff is clean and ready to merge"),
});
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
export type ReviewerConfig = {
/** Working directory of the project */
cwd: string;
/** Path to conventions/standards file, relative to cwd. Default: "CONVENTIONS.md" */
conventionsPath: string | null;
/** Extra checklist items appended to the review criteria */
extraChecks: ReadonlyArray<string>;
};
const defaults: ReviewerConfig = {
cwd: ".",
conventionsPath: "CONVENTIONS.md",
extraChecks: [],
};
function reviewerPrompt({
threadId,
config,
}: { threadId: string; config: ReviewerConfig }): string {
const { cwd, conventionsPath, extraChecks } = config;
const conventionsBlock = conventionsPath
? `Read project conventions: \`cat ${cwd}/${conventionsPath}\`\n`
: "";
const extraBlock =
extraChecks.length > 0
? `\n### 📋 Project-specific checks\n${extraChecks.map((c) => `- ${c}`).join("\n")}\n`
: "";
return `You are a **code reviewer**. You run after the coder and before the tester.
**IMPORTANT: The project is at \`${cwd}\`. Always \`cd ${cwd}\` first.**
Read the workflow thread for context: \`nerve thread ${threadId}\`
${conventionsBlock}
## Your job — static analysis of the git diff
Run these commands and analyze the output:
1. **\`cd ${cwd} && git diff --stat\`** — see what files changed
2. **\`cd ${cwd} && git diff\`** — read the actual diff
3. **\`cd ${cwd} && git status --short\`** — check for untracked files
## Checklist
### 🔴 Reject (approved: false) — tell coder exactly what to fix
- **Garbage files**: build artifacts, lockfiles, IDE config that shouldn't be committed
- **Secrets/credentials**: API keys, tokens, passwords hardcoded in the diff
- **Unrelated changes**: files modified outside the scope of the task
${conventionsPath ? `- **Convention violations**: patterns that contradict ${conventionsPath}\n` : ""}${extraBlock}
### ✅ Approve (approved: true) — no comment needed
- Diff is clean, focused, follows project standards
End with:
\`\`\`json
{ "approved": true }
\`\`\`
or
\`\`\`json
{ "approved": false }
\`\`\``;
}
/**
* Creates a reviewer role that performs static analysis on git diffs.
* Checks for garbage files, secrets, unrelated changes, and project conventions.
*/
export function createReviewerRole(
adapter: AgentFn,
extract: LlmExtractorConfig,
config: Partial<ReviewerConfig> = {},
): Role<ReviewerMeta> {
const resolved: ReviewerConfig = { ...defaults, ...config };
return createRole(
adapter,
async (ctx: ThreadContext) => reviewerPrompt({ threadId: ctx.threadId, config: resolved }),
reviewerMetaSchema,
extract,
);
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": false
},
"include": ["src"]
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@uncaged/nerve-workflow-meta",
"version": "0.5.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"publishConfig": {
"access": "public"
},
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*",
"@uncaged/nerve-role-committer": "workspace:*",
"@uncaged/nerve-role-reviewer": "workspace:*",
"@uncaged/nerve-workflow-utils": "workspace:*",
"zod": "^4.3.6"
},
"devDependencies": {
"@rslib/core": "^0.21.3",
"typescript": "^5.8.3",
"vitest": "^4.1.5"
}
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "@rslib/core";
export default defineConfig({
lib: [
{
format: "esm",
dts: true,
},
],
source: {
entry: {
index: "src/index.ts",
},
},
output: {
target: "node",
cleanDistPath: true,
},
});
@@ -0,0 +1,42 @@
import type { AgentFn, WorkflowDefinition } from "@uncaged/nerve-core";
import { createCommitterRole } from "@uncaged/nerve-role-committer";
import { createReviewerRole } from "@uncaged/nerve-role-reviewer";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { moderator } from "./moderator.js";
import type { SenseMeta } from "./moderator.js";
import { createCoderRole } from "./roles/coder.js";
import { createPlannerRole } from "./roles/planner.js";
import { createTesterRole } from "./roles/tester.js";
export type CreateDevelopSenseDeps = {
defaultAdapter: AgentFn;
adapters: Partial<Record<keyof SenseMeta, AgentFn>> | null;
extract: LlmExtractorConfig;
cwd: string;
};
export function createDevelopSenseWorkflow({
defaultAdapter,
adapters,
extract,
cwd,
}: CreateDevelopSenseDeps): WorkflowDefinition<SenseMeta> {
const a = (role: keyof SenseMeta) => adapters?.[role] ?? defaultAdapter;
const roles = {
planner: createPlannerRole(a("planner"), extract),
coder: createCoderRole(a("coder"), extract),
reviewer: createReviewerRole(a("reviewer"), extract, {
cwd,
conventionsPath: "CONVENTIONS.md",
}),
tester: createTesterRole(a("tester"), extract, cwd),
committer: createCommitterRole(a("committer"), extract),
};
return {
name: "develop-sense",
roles,
moderator,
};
}
@@ -0,0 +1,65 @@
import { END } from "@uncaged/nerve-core";
import type { Moderator } from "@uncaged/nerve-core";
import type { CommitterMeta } from "@uncaged/nerve-role-committer";
import type { ReviewerMeta } from "@uncaged/nerve-role-reviewer";
import type { CoderMeta } from "./roles/coder.js";
import type { PlannerMeta } from "./roles/planner.js";
import type { TesterMeta } from "./roles/tester.js";
export type SenseMeta = {
planner: PlannerMeta;
coder: CoderMeta;
reviewer: ReviewerMeta;
tester: TesterMeta;
committer: CommitterMeta;
};
const MAX_CODER_ROUNDS = 20;
const MAX_TOTAL_REJECTIONS = 10;
function coderRounds(steps: { role: string }[]): number {
return steps.filter((s) => s.role === "coder").length;
}
function totalRejections(steps: { role: string; meta: unknown }[]): number {
return steps.filter((s) => {
if (s.role === "reviewer") return !(s.meta as Record<string, boolean>).approved;
if (s.role === "tester") return !(s.meta as Record<string, boolean>).passed;
if (s.role === "committer") return !(s.meta as Record<string, boolean>).committed;
return false;
}).length;
}
function canRetryCoder(steps: { role: string; meta: unknown }[]): boolean {
return coderRounds(steps) < MAX_CODER_ROUNDS && totalRejections(steps) < MAX_TOTAL_REJECTIONS;
}
export const moderator: Moderator<SenseMeta> = (context) => {
if (context.steps.length === 0) return "planner";
const last = context.steps[context.steps.length - 1];
if (last.role === "planner") return "coder";
if (last.role === "coder") {
if (last.meta.filesCreated) return "reviewer";
return canRetryCoder(context.steps) ? "coder" : END;
}
if (last.role === "reviewer") {
if (last.meta.approved) return "tester";
return canRetryCoder(context.steps) ? "coder" : END;
}
if (last.role === "tester") {
if (last.meta.passed) return "committer";
return canRetryCoder(context.steps) ? "coder" : END;
}
if (last.role === "committer") {
if (last.meta.committed) return END;
return canRetryCoder(context.steps) ? "coder" : END;
}
return END;
};
@@ -0,0 +1,50 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
export const coderMetaSchema = z.object({
filesCreated: z.boolean().describe("true if the sense files were created"),
});
export type CoderMeta = z.infer<typeof coderMetaSchema>;
export function coderPrompt({ threadId }: { threadId: string }): string {
return `Read the workflow thread for the planner's sense design and any tester feedback: \`nerve thread ${threadId}\`
Read the nerve-dev skill for sense file structure and conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
## Your task
Implement (or fix) the sense the planner designed. If there is tester feedback in the thread, fix the issues it identified.
## Multi-step approach
You do NOT need to finish everything in one pass. You may return \`done: false\` to continue in the next iteration.
## File structure for each sense
- \`senses/<name>/src/index.ts\` — TypeScript compute source; import schema as \`./schema.ts\`
- \`senses/<name>/src/schema.ts\` — Drizzle schema (TypeScript)
- \`senses/<name>/migrations/\` — Drizzle migration files (at sense root, not inside src/)
- \`senses/<name>/package.json\` — with esbuild build script
- \`senses/<name>/index.js\` — bundled output generated by \`pnpm build\` (do NOT edit by hand)
Look at existing senses for the package.json template and patterns.
## When to return done: true
Return \`done: true\` ONLY when ALL of the following are true:
- All required files are created
- \`pnpm install --no-cache && pnpm build\` succeeds (run it!)
- \`nerve.yaml\` is updated with the sense config
Return \`done: false\` if you made progress but there is still work to do.`;
}
export function createCoderRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<CoderMeta> {
return createRole(
adapter,
async (ctx: ThreadContext) => coderPrompt({ threadId: ctx.threadId }),
coderMetaSchema,
extract,
);
}
@@ -0,0 +1,39 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
export const plannerMetaSchema = z.object({
senseName: z.string().describe("kebab-case sense name from the plan"),
});
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
export function plannerPrompt({ threadId }: { threadId: string }): string {
return `You are planning a new Nerve sense.
Read the workflow thread for the user's request: \`nerve thread ${threadId}\`
Read the nerve-dev skill for sense conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
Also look at existing senses in the \`senses/\` directory for patterns.
Pick a good kebab-case name for this sense. Produce a PLAN (not code) in markdown:
## Sense Design
### Name — kebab-case
### Fields — name, type (integer/real/text), description
### Compute Logic — step-by-step, specific Node.js APIs or shell commands
### Trigger Config — group, interval, throttle, timeout
Output ONLY the plan. Be precise and implementation-ready.`;
}
export function createPlannerRole(
adapter: AgentFn,
extract: LlmExtractorConfig,
): Role<PlannerMeta> {
return createRole(
adapter,
async (ctx: ThreadContext) => plannerPrompt({ threadId: ctx.threadId }),
plannerMetaSchema,
extract,
);
}
@@ -0,0 +1,60 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
export const testerMetaSchema = z.object({
passed: z.boolean().describe("true if all e2e checks passed"),
});
export type TesterMeta = z.infer<typeof testerMetaSchema>;
export function testerPrompt({
threadId,
nerveRoot,
}: { threadId: string; nerveRoot: string }): string {
return `You are testing a newly created Nerve sense end-to-end.
**IMPORTANT: The Nerve workspace is at \`${nerveRoot}\`. All paths below are relative to this directory. Always \`cd ${nerveRoot}\` first.**
Read the workflow thread for context: \`nerve thread ${threadId}\`
Read the nerve-dev skill for expected file structure: \`cat ${nerveRoot}/node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
Verify the full lifecycle in this order:
1. **File check** — all required sense files exist:
- \`senses/<name>/src/index.ts\`
- \`senses/<name>/src/schema.ts\`
- \`senses/<name>/migrations/\`
- \`senses/<name>/package.json\`
2. **Build** — run inside the sense directory:
\`\`\`
cd ${nerveRoot}/senses/<name> && pnpm install --no-cache && pnpm build
\`\`\`
Must produce \`index.js\` at sense root without errors.
3. **Config check** — \`nerve validate\` passes, confirming nerve.yaml is valid.
4. **Sense list** — \`nerve sense list\` shows the sense.
5. **Trigger** — \`nerve sense trigger <name>\` completes without error.
6. **Query** — \`nerve sense query <name>\` — retry up to 20s until rows appear.
If any step fails, include the relevant error output.
Output a clear summary: what you checked, what passed, what failed, and why.`;
}
export function createTesterRole(
adapter: AgentFn,
extract: LlmExtractorConfig,
nerveRoot: string,
): Role<TesterMeta> {
return createRole(
adapter,
async (ctx: ThreadContext) => testerPrompt({ threadId: ctx.threadId, nerveRoot }),
testerMetaSchema,
extract,
);
}
@@ -0,0 +1,42 @@
import type { AgentFn, WorkflowDefinition } from "@uncaged/nerve-core";
import { createCommitterRole } from "@uncaged/nerve-role-committer";
import { createReviewerRole } from "@uncaged/nerve-role-reviewer";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { moderator } from "./moderator.js";
import type { WorkflowMeta } from "./moderator.js";
import { createCoderRole } from "./roles/coder.js";
import { createPlannerRole } from "./roles/planner.js";
import { createTesterRole } from "./roles/tester.js";
export type CreateDevelopWorkflowDeps = {
defaultAdapter: AgentFn;
adapters: Partial<Record<keyof WorkflowMeta, AgentFn>> | null;
extract: LlmExtractorConfig;
nerveRoot: string;
};
export function createDevelopWorkflowWorkflow({
defaultAdapter,
adapters,
extract,
nerveRoot,
}: CreateDevelopWorkflowDeps): WorkflowDefinition<WorkflowMeta> {
const a = (role: keyof WorkflowMeta) => adapters?.[role] ?? defaultAdapter;
const roles = {
planner: createPlannerRole(a("planner"), extract),
coder: createCoderRole(a("coder"), extract),
reviewer: createReviewerRole(a("reviewer"), extract, {
cwd: nerveRoot,
conventionsPath: "CONVENTIONS.md",
}),
tester: createTesterRole(a("tester"), extract, nerveRoot),
committer: createCommitterRole(a("committer"), extract),
};
return {
name: "develop-workflow",
roles,
moderator,
};
}
@@ -0,0 +1,67 @@
import { END } from "@uncaged/nerve-core";
import type { Moderator } from "@uncaged/nerve-core";
import type { CommitterMeta } from "@uncaged/nerve-role-committer";
import type { ReviewerMeta } from "@uncaged/nerve-role-reviewer";
import type { CoderMeta } from "./roles/coder.js";
import type { PlannerMeta } from "./roles/planner.js";
import type { TesterMeta } from "./roles/tester.js";
export type WorkflowMeta = {
planner: PlannerMeta;
coder: CoderMeta;
reviewer: ReviewerMeta;
tester: TesterMeta;
committer: CommitterMeta;
};
const MAX_CODER_ROUNDS = 20;
const MAX_TOTAL_REJECTIONS = 10;
function coderRounds(steps: { role: string }[]): number {
return steps.filter((s) => s.role === "coder").length;
}
function totalRejections(steps: { role: string; meta: unknown }[]): number {
return steps.filter((s) => {
if (s.role === "reviewer") return !(s.meta as Record<string, boolean>).approved;
if (s.role === "tester") return !(s.meta as Record<string, boolean>).passed;
if (s.role === "committer") return !(s.meta as Record<string, boolean>).committed;
return false;
}).length;
}
function canRetryCoder(steps: { role: string; meta: unknown }[]): boolean {
return coderRounds(steps) < MAX_CODER_ROUNDS && totalRejections(steps) < MAX_TOTAL_REJECTIONS;
}
export const moderator: Moderator<WorkflowMeta> = (context) => {
if (context.steps.length === 0) return "planner";
const last = context.steps[context.steps.length - 1];
if (last.role === "planner") {
return last.meta.ready ? "coder" : END;
}
if (last.role === "coder") {
if (last.meta.done) return "reviewer";
return canRetryCoder(context.steps) ? "coder" : END;
}
if (last.role === "reviewer") {
if (last.meta.approved) return "tester";
return canRetryCoder(context.steps) ? "coder" : END;
}
if (last.role === "tester") {
if (last.meta.passed) return "committer";
return canRetryCoder(context.steps) ? "coder" : END;
}
if (last.role === "committer") {
if (last.meta.committed) return END;
return canRetryCoder(context.steps) ? "coder" : END;
}
return END;
};
@@ -0,0 +1,69 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
export const coderMetaSchema = z.object({
done: z.boolean().describe("true if the workflow files were created and build passes"),
});
export type CoderMeta = z.infer<typeof coderMetaSchema>;
export function coderPrompt({ threadId }: { threadId: string }): string {
return `Read the workflow thread to get the planner's design and any reviewer/tester/committer feedback: \`nerve thread ${threadId}\`
Read the nerve-dev skill for workflow file structure and conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
Also look at existing workflows in the \`workflows/\` directory for patterns.
## Your task
Implement the planner's design. This may be **creating a new workflow** or **modifying an existing one**. If there is reviewer, tester, or committer feedback in the thread, fix the issues they identified.
**IMPORTANT:** The thread contains both the **initial user prompt** (the first message) and the **planner's design**. Read both carefully:
- The **initial prompt** contains the user's specific requirements for role behavior, tools to use, and acceptance criteria
- The **planner's design** contains the architecture, file structure, and routing logic
- When writing role prompts, follow the user's behavioral requirements from the initial prompt — do not invent your own interpretation
## Multi-step approach
You do NOT need to finish everything in one pass. You may return \`done: false\` to continue in the next iteration. For example:
1. First pass: scaffold files / make structural changes
2. Second pass: implement role logic
3. Third pass: fix build/lint errors
## Workflow file structure
Each workflow must have:
- \`workflows/<name>/index.ts\` — WorkflowDefinition default export
- \`workflows/<name>/build.ts\` — factory function
- \`workflows/<name>/moderator.ts\` — moderator + meta types
- \`workflows/<name>/roles/<role>.ts\` — meta schema and prompt function per role
- \`workflows/<name>/package.json\` — with esbuild build script
- \`workflows/<name>/tsconfig.json\` — TypeScript config
For **new workflows**, also update \`nerve.yaml\` with \`workflows.<name>\`.
## Rules
- Keep the WorkflowDefinition<WorkflowMeta> pattern
- No dynamic import()
- Use types (not interfaces)
- Meta should be simple routing signals (single boolean per role)
- Write compile-ready TypeScript
## When to return done: true
Return \`done: true\` ONLY when ALL of the following are true:
- All changes from the plan are implemented
- \`cd workflows/<name> && pnpm install --no-cache && pnpm build\` succeeds (run it!)
- No lint or type errors remain
Return \`done: false\` if you made progress but there is still work to do, or if build/lint has errors you plan to fix in the next iteration.`;
}
export function createCoderRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<CoderMeta> {
return createRole(
adapter,
async (ctx: ThreadContext) => coderPrompt({ threadId: ctx.threadId }),
coderMetaSchema,
extract,
);
}
@@ -0,0 +1,68 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
export const plannerMetaSchema = z.object({
ready: z.boolean().describe("true if requirements are clear and a workflow can be implemented"),
});
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
export function plannerPrompt({ threadId }: { threadId: string }): string {
return `You are a Nerve workflow planner. You can **create new workflows** or **modify existing ones**.
Read the workflow thread for the user's request: \`nerve thread ${threadId}\`
Read the nerve-dev skill for workflow conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
List existing workflows: \`ls workflows/\`
## Determine the task type
1. If the user wants to **modify an existing workflow** — read its current code (\`cat workflows/<name>/moderator.ts\`, \`cat workflows/<name>/build.ts\`, \`ls workflows/<name>/roles/\`, etc.) and understand its current structure before planning changes.
2. If the user wants to **create a new workflow** — look at existing workflows in \`workflows/\` for patterns to follow.
## Produce a PLAN (not code) in markdown
For **new workflows**:
- Workflow name (kebab-case)
- Roles list (name, purpose, tool)
- Flow transitions / moderator routing logic
- Validation loops design
- External dependencies
- Data flow between roles
For **modifications to existing workflows**:
- Workflow name (existing)
- What changes are needed and why
- Files to add/modify/delete
- Impact on moderator routing logic (this workflow's typical order is planner → coder → reviewer → tester → committer)
- Backward compatibility considerations (if any)
**For every role (new or modified)**, include a **Role Behavior** section that describes:
- What the role should do, check, or produce
- What tools or commands it should use
- What criteria determine its meta output (e.g. approved/passed/done)
- Preserve the user's specific requirements verbatim — do NOT summarize away details
If requirements are NOT clear, describe what is missing or ambiguous.
End your response with a JSON block:
\`\`\`json
{ "ready": true }
\`\`\`
or
\`\`\`json
{ "ready": false }
\`\`\``;
}
export function createPlannerRole(
adapter: AgentFn,
extract: LlmExtractorConfig,
): Role<PlannerMeta> {
return createRole(
adapter,
async (ctx: ThreadContext) => plannerPrompt({ threadId: ctx.threadId }),
plannerMetaSchema,
extract,
);
}
@@ -0,0 +1,61 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
export const testerMetaSchema = z.object({
passed: z.boolean().describe("true if all validation checks passed"),
});
export type TesterMeta = z.infer<typeof testerMetaSchema>;
export function testerPrompt({
threadId,
nerveRoot,
}: { threadId: string; nerveRoot: string }): string {
return `You are testing a Nerve workflow — either newly created or recently modified.
**IMPORTANT: The Nerve workspace is at \`${nerveRoot}\`. All paths below are relative to this directory. Always \`cd ${nerveRoot}\` first.**
Read the workflow thread for context: \`nerve thread ${threadId}\`
Read the nerve-dev skill for expected file structure: \`cat ${nerveRoot}/node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
Get the workflow name from the thread (the planner's output).
Verify the full lifecycle in this order:
1. **File check** — all required workflow files exist (under \`${nerveRoot}/\`):
- \`workflows/<name>/index.ts\`
- \`workflows/<name>/build.ts\`
- \`workflows/<name>/moderator.ts\`
- \`workflows/<name>/roles/\` with one \`.ts\` file per role
- \`workflows/<name>/package.json\`
2. **Build** — run inside the workflow directory:
\`\`\`
cd ${nerveRoot}/workflows/<name> && pnpm install --no-cache && pnpm build
\`\`\`
Must produce \`dist/index.js\` without errors.
3. **Config check** — \`cd ${nerveRoot} && nerve validate\` passes, confirming nerve.yaml is valid.
4. **Workflow list** — \`nerve workflow list\` shows the workflow.
5. **Trigger test** — \`nerve workflow trigger <name> --dry-run\` if available, otherwise just confirm the workflow appears in \`nerve workflow status\`.
If any step fails, include the relevant error output.
Output a clear summary: what you checked, what passed, what failed, and why.`;
}
export function createTesterRole(
adapter: AgentFn,
extract: LlmExtractorConfig,
nerveRoot: string,
): Role<TesterMeta> {
return createRole(
adapter,
async (ctx: ThreadContext) => testerPrompt({ threadId: ctx.threadId, nerveRoot }),
testerMetaSchema,
extract,
);
}
+4
View File
@@ -0,0 +1,4 @@
export { createDevelopSenseWorkflow } from "./develop-sense/build.js";
export type { CreateDevelopSenseDeps } from "./develop-sense/build.js";
export { createDevelopWorkflowWorkflow } from "./develop-workflow/build.js";
export type { CreateDevelopWorkflowDeps } from "./develop-workflow/build.js";
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": false
},
"include": ["src"]
}
@@ -0,0 +1,182 @@
import type {
AgentFn,
ModeratorContext,
RoleMeta,
ThreadContext,
WorkflowDefinition,
} from "@uncaged/nerve-core";
import { END, START } from "@uncaged/nerve-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { createRole } from "../create-role.js";
import * as extractFn from "../shared/extract-fn.js";
const provider = {
baseUrl: "https://example.com/v1",
apiKey: "k",
model: "m",
};
function toolCallResponse(argsJson: string): {
ok: boolean;
status: number;
text: () => Promise<string>;
} {
return {
ok: true,
status: 200,
text: async () =>
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
function: {
name: "extract",
arguments: argsJson,
},
},
],
},
},
],
}),
};
}
function makeStart(threadId: string): {
role: typeof START;
content: string;
meta: { maxRounds: number; threadId: string };
timestamp: number;
} {
return {
role: START,
content: "",
meta: { maxRounds: 10, threadId },
timestamp: Date.now(),
};
}
function makeCtx(threadId: string): ThreadContext {
return {
threadId,
start: makeStart(threadId),
steps: [],
};
}
describe("createRole", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("runs AgentFn then structured extract", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 3 }))));
const schema = z.object({ n: z.number() });
const adapter: AgentFn = async (_ctx, prompt) => prompt;
const role = createRole(adapter, "hello", schema, { provider, dryRun: null });
const out = await role(makeCtx("t1"));
expect(out.content).toBe("hello");
expect(out.meta).toEqual({ n: 3 });
});
it("passes ThreadContext to AgentFn", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 0 }))));
const seen: ThreadContext[] = [];
const adapter: AgentFn = async (ctx, _prompt) => {
seen.push(ctx);
return "x";
};
const role = createRole(adapter, "p", z.object({ n: z.number() }), { provider, dryRun: null });
await role(makeCtx("t1"));
expect(seen).toHaveLength(1);
expect(seen[0].threadId).toBe("t1");
expect(seen[0].steps).toEqual([]);
});
it("resolves dynamic prompt functions before AgentFn", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 99 }))));
const schema = z.object({ n: z.number() });
const adapter: AgentFn = async (_ctx, prompt) => prompt;
const role = createRole(
adapter,
async (ctx) => `tid=${ctx.threadId} n=${ctx.steps.length}`,
schema,
{ provider, dryRun: null },
);
const ctx: ThreadContext = {
threadId: "thread-x",
start: makeStart("thread-x"),
steps: [],
};
const out = await role(ctx);
expect(out.content).toBe("tid=thread-x n=0");
expect(out.meta).toEqual({ n: 99 });
});
it("uses extract dryRun null as live extract", async () => {
const spy = vi.spyOn(extractFn, "extractMetaOrThrow").mockResolvedValue({ n: 0 });
const adapter: AgentFn = async () => "raw";
const role = createRole(adapter, "p", z.object({ n: z.number() }), {
provider,
dryRun: null,
});
await role(makeCtx("x"));
expect(spy).toHaveBeenCalledWith(
"raw",
expect.anything(),
expect.objectContaining({ provider, dryRun: false }),
);
});
it("uses extract.dryRun true for structured extract", async () => {
const spy = vi.spyOn(extractFn, "extractMetaOrThrow").mockResolvedValue({ n: 0 });
const adapter: AgentFn = async () => "raw";
const role = createRole(adapter, "p", z.object({ n: z.number() }), {
provider,
dryRun: true,
});
await role(makeCtx("x"));
expect(spy).toHaveBeenCalledWith(
"raw",
expect.anything(),
expect.objectContaining({ dryRun: true }),
);
});
});
describe("WorkflowDefinition compatibility", () => {
it("hand-written Role-based WorkflowDefinition remains valid", async () => {
type M = RoleMeta & { legacy: { id: string } };
const manual: WorkflowDefinition<M> = {
name: "legacy",
roles: {
legacy: async (_ctx) => ({
content: "hi",
meta: { id: "a" },
}),
},
moderator: (_ctx: ModeratorContext<M>) => END,
};
const ctx = makeCtx("t1");
const out = await manual.roles.legacy(ctx);
expect(out.content).toBe("hi");
expect(out.meta.id).toBe("a");
});
});
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import type { Role, ThreadContext } from "@uncaged/nerve-core";
import { START } from "@uncaged/nerve-core";
import { decorateRole, onFail, withDryRun } from "../role-decorators.js";
type TestMeta = Record<string, unknown> & { ok: boolean };
function fakeCtx(): ThreadContext {
return {
threadId: "t1",
start: {
role: START,
content: "",
meta: {
threadId: "t1",
maxRounds: 10,
},
timestamp: Date.now(),
},
steps: [],
};
}
const successRole: Role<TestMeta> = async () => ({
content: "done",
meta: { ok: true },
});
const failRole: Role<TestMeta> = async () => {
throw new Error("boom");
};
const failNonErrorRole: Role<TestMeta> = async () => {
throw "string error";
};
// ---------------------------------------------------------------------------
// withDryRun
// ---------------------------------------------------------------------------
describe("withDryRun", () => {
const dec = withDryRun<TestMeta>({ label: "test", meta: { ok: true }, dryRun: true });
it("short-circuits on dry-run", async () => {
const role = dec(successRole);
const result = await role(fakeCtx());
expect(result.content).toBe("[dry-run] test skipped");
expect(result.meta).toEqual({ ok: true });
});
it("delegates when not dry-run", async () => {
const innerDec = withDryRun<TestMeta>({ label: "test", meta: { ok: true }, dryRun: false });
const role = innerDec(successRole);
const result = await role(fakeCtx());
expect(result.content).toBe("done");
expect(result.meta).toEqual({ ok: true });
});
});
// ---------------------------------------------------------------------------
// onFail
// ---------------------------------------------------------------------------
describe("onFail", () => {
const dec = onFail<TestMeta>({ label: "test", meta: { ok: false } });
it("passes through on success", async () => {
const role = dec(successRole);
const result = await role(fakeCtx());
expect(result.content).toBe("done");
expect(result.meta).toEqual({ ok: true });
});
it("catches Error and returns structured failure", async () => {
const role = dec(failRole);
const result = await role(fakeCtx());
expect(result.content).toBe("test failed: boom");
expect(result.meta).toEqual({ ok: false });
});
it("catches non-Error throws", async () => {
const role = dec(failNonErrorRole);
const result = await role(fakeCtx());
expect(result.content).toBe("test failed: string error");
expect(result.meta).toEqual({ ok: false });
});
});
// ---------------------------------------------------------------------------
// decorateRole
// ---------------------------------------------------------------------------
describe("decorateRole", () => {
it("applies decorators left-to-right", async () => {
const role = decorateRole(failRole, [
withDryRun<TestMeta>({ label: "x", meta: { ok: true }, dryRun: false }),
onFail<TestMeta>({ label: "x", meta: { ok: false } }),
]);
// Not dry-run, so withDryRun passes through → failRole throws → onFail catches
const result = await role(fakeCtx());
expect(result.content).toBe("x failed: boom");
expect(result.meta).toEqual({ ok: false });
});
it("dry-run short-circuits before onFail", async () => {
const role = decorateRole(failRole, [
withDryRun<TestMeta>({ label: "x", meta: { ok: true }, dryRun: true }),
onFail<TestMeta>({ label: "x", meta: { ok: false } }),
]);
const result = await role(fakeCtx());
expect(result.content).toBe("[dry-run] x skipped");
expect(result.meta).toEqual({ ok: true });
});
});
@@ -1,20 +1,24 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { START } from "@uncaged/nerve-core";
import { START, type ThreadContext } from "@uncaged/nerve-core";
import { createCursorRole } from "../role-cursor.js";
import { createHermesRole } from "../role-hermes.js";
import { createLlmRole } from "../role-llm.js";
import { createReActRole } from "../role-react.js";
function startFrame(dryRun: boolean, threadId: string) {
function threadCtx(threadId: string): ThreadContext {
return {
role: START,
content: "user prompt",
meta: { maxRounds: 10, dryRun, threadId },
timestamp: 1,
} as const;
threadId,
start: {
role: START,
content: "user prompt",
meta: { maxRounds: 10, threadId },
timestamp: 1,
},
steps: [],
};
}
describe("createCursorRole", () => {
@@ -27,13 +31,14 @@ describe("createCursorRole", () => {
const schema = z.object({ done: z.boolean() });
const role = createCursorRole({
cwd: process.cwd(),
prompt: async (tid) => {
expect(tid).toBe("run-1");
prompt: async (ctx) => {
expect(ctx.threadId).toBe("run-1");
return "task";
},
extract: { provider: { baseUrl: "https://x", apiKey: "k", model: "m" }, schema },
dryRun: true,
});
const out = await role(startFrame(true, "run-1"), []);
const out = await role(threadCtx("run-1"));
expect(out.content).toContain("dryRun");
expect(out.meta).toEqual({ done: false });
});
@@ -48,13 +53,14 @@ describe("createHermesRole", () => {
it("uses dry run stub and extract defaults", async () => {
const schema = z.object({ done: z.boolean() });
const role = createHermesRole({
prompt: async (tid) => {
expect(tid).toBe("h1");
prompt: async (ctx) => {
expect(ctx.threadId).toBe("h1");
return "hermes task";
},
extract: { provider: { baseUrl: "https://x", apiKey: "k", model: "m" }, schema },
dryRun: true,
});
const out = await role(startFrame(true, "h1"), []);
const out = await role(threadCtx("h1"));
expect(out.content).toContain("hermes");
expect(out.meta).toEqual({ done: false });
});
@@ -99,13 +105,13 @@ describe("createLlmRole", () => {
const role = createLlmRole({
provider: { baseUrl: "https://api", apiKey: "k", model: "gpt" },
prompt: async (tid) => {
expect(tid).toBe("llm1");
prompt: async (ctx) => {
expect(ctx.threadId).toBe("llm1");
return [{ role: "user" as const, content: "hi" }];
},
extract: { provider: { baseUrl: "https://ext", apiKey: "k2", model: "small" }, schema },
});
const out = await role(startFrame(false, "llm1"), []);
const out = await role(threadCtx("llm1"));
expect(out.content).toBe("hello from model");
expect(out.meta).toEqual({ n: 7 });
});
@@ -183,8 +189,8 @@ describe("createReActRole", () => {
const role = createReActRole({
provider: { baseUrl: "https://api", apiKey: "k", model: "gpt" },
tools: [tool],
prompt: async (tid) => {
expect(tid).toBe("r1");
prompt: async (ctx) => {
expect(ctx.threadId).toBe("r1");
return [{ role: "user" as const, content: "go" }];
},
extract: {
@@ -193,7 +199,7 @@ describe("createReActRole", () => {
},
maxIterations: 5,
});
const out = await role(startFrame(false, "r1"), []);
const out = await role(threadCtx("r1"));
expect(out.content).toBe("final answer");
expect(out.meta).toEqual({ done: true });
});
@@ -0,0 +1,35 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
import type { z } from "zod";
import { extractMetaOrThrow } from "./shared/extract-fn.js";
import type { LlmProvider } from "./shared/llm-extract.js";
type PromptInput = string | ((ctx: ThreadContext) => Promise<string>);
export type LlmExtractorConfig = {
provider: LlmProvider;
/** When null, structured extract runs in live mode (not dry-run). */
dryRun: boolean | null;
};
function resolveExtractDryRun(extract: LlmExtractorConfig): boolean {
return extract.dryRun === true;
}
/** Builds a Role from an AgentFn, prompt, Zod meta schema, and LLM extract config. */
export function createRole<M extends Record<string, unknown>>(
adapter: AgentFn,
prompt: PromptInput,
meta: z.ZodType<M>,
extract: LlmExtractorConfig,
): Role<M> {
return async (ctx: ThreadContext) => {
const promptText = typeof prompt === "string" ? prompt : await prompt(ctx);
const raw = await adapter(ctx, promptText);
const result = await extractMetaOrThrow(raw, meta, {
provider: extract.provider,
dryRun: resolveExtractDryRun(extract),
});
return { content: raw, meta: result };
};
}
+12 -1
View File
@@ -1,4 +1,5 @@
// Primary API — role factory templates
export { createRole, type LlmExtractorConfig } from "./create-role.js";
export { createCursorRole } from "./role-cursor.js";
export { createHermesRole } from "./role-hermes.js";
export { createLlmRole } from "./role-llm.js";
@@ -9,6 +10,7 @@ export {
assertZodMetaSchemas,
createLlmExtractFn,
extractMetaOrThrow,
zodMeta,
type ZodMetaSchema,
} from "./shared/extract-fn.js";
export {
@@ -17,7 +19,14 @@ export {
type NerveYamlError,
type ReadNerveYamlOptions,
} from "./shared/context.js";
export { isDryRun } from "./role-types.js";
export {
decorateRole,
withDryRun,
onFail,
type RoleDecorator,
type WithDryRunOptions,
type OnFailOptions,
} from "./role-decorators.js";
export {
nerveCommandEnv,
spawnSafe,
@@ -27,6 +36,7 @@ export {
type SpawnSafeOptions,
} from "@uncaged/nerve-core";
export type { LlmError, LlmProvider } from "./shared/llm-extract.js";
export { isDryRun } from "./role-types.js";
export type {
CliPromptFn,
CursorRoleDefaults,
@@ -35,6 +45,7 @@ export type {
HermesRoleRequired,
LlmMessage,
LlmPromptFn,
LlmRoleDefaults,
LlmRoleRequired,
MetaExtractConfig,
ReActRoleDefaults,
+4 -4
View File
@@ -2,7 +2,6 @@ import { type CursorAgentMode, cursorAgent } from "@uncaged/nerve-adapter-cursor
import type { Role, SpawnEnv } from "@uncaged/nerve-core";
import type { CursorRoleDefaults, CursorRoleRequired } from "./role-types.js";
import { isDryRun } from "./role-types.js";
import { formatLlmError } from "./shared/format-error.js";
import { llmExtract } from "./shared/llm-extract.js";
@@ -11,6 +10,7 @@ const CURSOR_DEFAULTS: CursorRoleDefaults = {
model: "auto",
env: {},
timeoutMs: 300_000,
dryRun: false,
};
function pick<T>(opts: Record<string, unknown>, key: string, fallback: T): T {
@@ -27,14 +27,14 @@ function pick<T>(opts: Record<string, unknown>, key: string, fallback: T): T {
export function createCursorRole<T>(
options: CursorRoleRequired<T> & Partial<CursorRoleDefaults>,
): Role<T> {
return async (start, _messages) => {
const dry = isDryRun(start);
return async (ctx) => {
const d = CURSOR_DEFAULTS;
const mode = pick<CursorAgentMode>(options as Record<string, unknown>, "mode", d.mode);
const model = pick<string>(options as Record<string, unknown>, "model", d.model);
const env = pick<SpawnEnv>(options as Record<string, unknown>, "env", d.env);
const timeoutMs = pick<number>(options as Record<string, unknown>, "timeoutMs", d.timeoutMs);
const prompt = await options.prompt(start.meta.threadId);
const dry = pick<boolean>(options as Record<string, unknown>, "dryRun", d.dryRun);
const prompt = await options.prompt(ctx);
const run = await cursorAgent({
prompt,
mode,
@@ -0,0 +1,90 @@
import type { Role, ThreadContext } from "@uncaged/nerve-core";
// ---------------------------------------------------------------------------
// Decorator types
// ---------------------------------------------------------------------------
/** A role decorator: takes a role, returns an enhanced role. */
export type RoleDecorator<M extends Record<string, unknown>> = (role: Role<M>) => Role<M>;
// ---------------------------------------------------------------------------
// decorateRole — compose a chain of decorators
// ---------------------------------------------------------------------------
/**
* Apply an ordered list of decorators to a role.
* Decorators are applied left-to-right (first in list wraps innermost).
*
* ```ts
* decorateRole(role, [withDryRun(opts), onFail(opts)]);
* // equivalent to: onFail(opts)(withDryRun(opts)(role))
* ```
*/
export function decorateRole<M extends Record<string, unknown>>(
role: Role<M>,
decorators: RoleDecorator<M>[],
): Role<M> {
return decorators.reduce((r, dec) => dec(r), role);
}
// ---------------------------------------------------------------------------
// withDryRun — skip execution when dry-run is active
// ---------------------------------------------------------------------------
export type WithDryRunOptions<M> = {
/** Used in skip message (e.g. "committer", "publish"). */
label: string;
/** Meta returned when dry-run skips execution. */
meta: M;
/** Adapter-level dry-run flag (e.g. from extract / wiring config). */
dryRun: boolean;
};
/**
* Returns a decorator that short-circuits with a stable result when
* `dryRun` is true.
*/
export function withDryRun<M extends Record<string, unknown>>(
opts: WithDryRunOptions<M>,
): RoleDecorator<M> {
return (role) => async (ctx: ThreadContext) => {
if (opts.dryRun) {
return {
content: `[dry-run] ${opts.label} skipped`,
meta: opts.meta,
};
}
return role(ctx);
};
}
// ---------------------------------------------------------------------------
// onFail — catch errors and return a structured failure result
// ---------------------------------------------------------------------------
export type OnFailOptions<M> = {
/** Used in failure message (e.g. "committer", "publish"). */
label: string;
/** Meta returned when the inner role throws. */
meta: M;
};
/**
* Returns a decorator that catches thrown errors and converts them into
* a structured RoleResult instead of propagating.
*/
export function onFail<M extends Record<string, unknown>>(
opts: OnFailOptions<M>,
): RoleDecorator<M> {
return (role) => async (ctx: ThreadContext) => {
try {
return await role(ctx);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
content: `${opts.label} failed: ${msg}`,
meta: opts.meta,
};
}
};
}
+4 -6
View File
@@ -1,7 +1,6 @@
import type { Role } from "@uncaged/nerve-core";
import type { HermesRoleDefaults, HermesRoleRequired } from "./role-types.js";
import { isDryRun } from "./role-types.js";
import { formatLlmError } from "./shared/format-error.js";
import { hermesAgent, resolveHermesOptions } from "./shared/hermes-agent.js";
import { llmExtract } from "./shared/llm-extract.js";
@@ -9,10 +8,9 @@ import { llmExtract } from "./shared/llm-extract.js";
export function createHermesRole<T>(
options: HermesRoleRequired<T> & Partial<HermesRoleDefaults>,
): Role<T> {
return async (start, _messages) => {
const dry = isDryRun(start);
return async (ctx) => {
const h = resolveHermesOptions(options);
const prompt = await options.prompt(start.meta.threadId);
const prompt = await options.prompt(ctx);
const run = await hermesAgent({
prompt,
model: h.model,
@@ -22,7 +20,7 @@ export function createHermesRole<T>(
maxTurns: h.maxTurns,
env: Object.keys(h.env).length === 0 ? null : h.env,
timeoutMs: h.timeoutMs,
dryRun: dry,
dryRun: h.dryRun,
abortSignal: null,
});
if (!run.ok) {
@@ -43,7 +41,7 @@ export function createHermesRole<T>(
text,
schema: options.extract.schema,
provider: options.extract.provider,
dryRun: dry,
dryRun: h.dryRun,
});
if (!metaR.ok) {
throw new Error(`llmExtract: ${formatLlmError(metaR.error)}`);
+10 -6
View File
@@ -1,15 +1,19 @@
import type { Role } from "@uncaged/nerve-core";
import type { LlmMessage, LlmRoleRequired } from "./role-types.js";
import { isDryRun } from "./role-types.js";
import type { LlmMessage, LlmRoleDefaults, LlmRoleRequired } from "./role-types.js";
import { formatLlmError } from "./shared/format-error.js";
import { chatCompletionText } from "./shared/llm-chat.js";
import { llmExtract } from "./shared/llm-extract.js";
export function createLlmRole<T>(options: LlmRoleRequired<T>): Role<T> {
return async (start, _messages) => {
const dry = isDryRun(start);
const messages: LlmMessage[] = await options.prompt(start.meta.threadId);
const LLM_DEFAULTS: LlmRoleDefaults = {
dryRun: false,
};
export function createLlmRole<T>(options: LlmRoleRequired<T> & Partial<LlmRoleDefaults>): Role<T> {
return async (ctx) => {
const dry =
"dryRun" in options && options.dryRun !== undefined ? options.dryRun : LLM_DEFAULTS.dryRun;
const messages: LlmMessage[] = await options.prompt(ctx);
const result = await chatCompletionText({ provider: options.provider, messages });
if (!result.ok) {
throw new Error(`llm: ${formatLlmError(result.error)}`);
+4 -4
View File
@@ -1,26 +1,26 @@
import type { Role } from "@uncaged/nerve-core";
import type { LlmMessage, ReActRoleDefaults, ReActRoleRequired } from "./role-types.js";
import { isDryRun } from "./role-types.js";
import { formatLlmError } from "./shared/format-error.js";
import { reActIterativeChat } from "./shared/llm-chat.js";
import { llmExtract } from "./shared/llm-extract.js";
const REACT_DEFAULTS: ReActRoleDefaults = {
maxIterations: 10,
dryRun: false,
};
export function createReActRole<T>(
options: ReActRoleRequired<T> & Partial<ReActRoleDefaults>,
): Role<T> {
return async (start, _messages) => {
const dry = isDryRun(start);
return async (ctx) => {
const def = REACT_DEFAULTS;
const maxIt =
"maxIterations" in options && options.maxIterations !== undefined
? options.maxIterations
: def.maxIterations;
const messages: LlmMessage[] = await options.prompt(start.meta.threadId);
const dry = "dryRun" in options && options.dryRun !== undefined ? options.dryRun : def.dryRun;
const messages: LlmMessage[] = await options.prompt(ctx);
const result = await reActIterativeChat({
provider: options.provider,
tools: options.tools,
+16 -6
View File
@@ -1,18 +1,21 @@
import type { SpawnEnv, StartStep } from "@uncaged/nerve-core";
import type { SpawnEnv, StartStep, ThreadContext } from "@uncaged/nerve-core";
import type { z } from "zod";
import type { LlmProvider } from "./shared/llm-extract.js";
/** Returns the thread-level dry-run flag from the workflow start frame. */
export function isDryRun(start: StartStep): boolean {
return start.meta.dryRun;
/**
* @deprecated `dryRun` has been removed from `StartStep.meta` (RFC-005).
* Use adapter/role-level `dryRun` config instead.
*/
export function isDryRun(_start: StartStep): boolean {
return false;
}
export type CliPromptFn = (threadId: string) => Promise<string>;
export type CliPromptFn = (ctx: ThreadContext) => Promise<string>;
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
export type LlmPromptFn = (threadId: string) => Promise<LlmMessage[]>;
export type LlmPromptFn = (ctx: ThreadContext) => Promise<LlmMessage[]>;
export type MetaExtractConfig<T> = {
provider: LlmProvider;
@@ -37,6 +40,7 @@ export type CursorRoleDefaults = {
model: string;
env: SpawnEnv;
timeoutMs: number;
dryRun: boolean;
};
export type HermesRoleRequired<T> = {
@@ -52,6 +56,7 @@ export type HermesRoleDefaults = {
maxTurns: number;
env: SpawnEnv;
timeoutMs: number;
dryRun: boolean;
};
export type LlmRoleRequired<T> = {
@@ -60,6 +65,10 @@ export type LlmRoleRequired<T> = {
extract: MetaExtractConfig<T>;
};
export type LlmRoleDefaults = {
dryRun: boolean;
};
export type ReActRoleRequired<T> = {
provider: LlmProvider;
tools: ReActTool[];
@@ -69,4 +78,5 @@ export type ReActRoleRequired<T> = {
export type ReActRoleDefaults = {
maxIterations: number;
dryRun: boolean;
};
@@ -10,6 +10,11 @@ import { llmErrorToCause, llmExtractWithRetry } from "./llm-extract.js";
*/
export type ZodMetaSchema<T> = Schema<T> & { readonly zod: z.ZodType<T> };
/** Builds a core `Schema<T>` plus Zod parser for `createRole` meta / `createLlmExtractFn`. */
export function zodMeta<T>(zod: z.ZodType<T>): ZodMetaSchema<T> {
return { witness: null, zod };
}
export async function extractMetaOrThrow<T>(
raw: string,
zodSchema: z.ZodType<T>,
@@ -45,8 +50,8 @@ export function createLlmExtractFn<T>(deps: {
}
/**
* Validate that all schemas in a WorkflowSpec are ZodMetaSchema at compile time,
* before any role is ever invoked. Call this once at daemon startup / hot-reload.
* Validate that all schemas are ZodMetaSchema before any role is invoked.
* Call this once at daemon startup / hot-reload when wiring roles manually.
*/
export function assertZodMetaSchemas(schemas: Record<string, Schema<unknown>>): void {
for (const [roleName, schema] of Object.entries(schemas)) {
@@ -13,22 +13,30 @@ const HERMES_DEFAULTS: HermesRoleDefaults = {
maxTurns: 90,
env: {},
timeoutMs: 600_000,
dryRun: false,
};
const HERMES_OPTION_KEYS = [
"model",
"provider",
"skills",
"quiet",
"maxTurns",
"env",
"timeoutMs",
"dryRun",
] as const satisfies readonly (keyof HermesRoleDefaults)[];
export function resolveHermesOptions<T>(
options: HermesRoleRequired<T> & Partial<HermesRoleDefaults>,
): HermesRoleDefaults {
const d = HERMES_DEFAULTS;
return {
model: "model" in options && options.model !== undefined ? options.model : d.model,
provider:
"provider" in options && options.provider !== undefined ? options.provider : d.provider,
skills: "skills" in options && options.skills !== undefined ? options.skills : d.skills,
quiet: "quiet" in options && options.quiet !== undefined ? options.quiet : d.quiet,
maxTurns:
"maxTurns" in options && options.maxTurns !== undefined ? options.maxTurns : d.maxTurns,
env: "env" in options && options.env !== undefined ? options.env : d.env,
timeoutMs:
"timeoutMs" in options && options.timeoutMs !== undefined ? options.timeoutMs : d.timeoutMs,
};
const o = options as Partial<HermesRoleDefaults>;
let resolved: HermesRoleDefaults = { ...d };
for (const k of HERMES_OPTION_KEYS) {
if (k in o && o[k] !== undefined) {
resolved = { ...resolved, [k]: o[k] } as HermesRoleDefaults;
}
}
return resolved;
}
+76
View File
@@ -86,6 +86,9 @@ importers:
packages/core:
dependencies:
drizzle-orm:
specifier: 1.0.0-beta.23-c10d10c
version: 1.0.0-beta.23-c10d10c(@cloudflare/workers-types@4.20260425.1)(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.11(@azure/core-client@1.10.1))(better-sqlite3@11.10.0)(mssql@11.0.1(@azure/core-client@1.10.1))(sql.js@1.14.1)(zod@4.3.6)
yaml:
specifier: ^2.8.3
version: 2.8.3
@@ -156,6 +159,50 @@ importers:
specifier: ^4.14.0
version: 4.85.0(@cloudflare/workers-types@4.20260425.1)
packages/role-committer:
dependencies:
'@uncaged/nerve-core':
specifier: workspace:*
version: link:../core
'@uncaged/nerve-workflow-utils':
specifier: workspace:*
version: link:../workflow-utils
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
'@rslib/core':
specifier: ^0.21.3
version: 0.21.3(typescript@5.9.3)
typescript:
specifier: ^5.8.3
version: 5.9.3
vitest:
specifier: ^4.1.5
version: 4.1.5(@types/node@25.6.0)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(yaml@2.8.3))
packages/role-reviewer:
dependencies:
'@uncaged/nerve-core':
specifier: workspace:*
version: link:../core
'@uncaged/nerve-workflow-utils':
specifier: workspace:*
version: link:../workflow-utils
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
'@rslib/core':
specifier: ^0.21.3
version: 0.21.3(typescript@5.9.3)
typescript:
specifier: ^5.8.3
version: 5.9.3
vitest:
specifier: ^4.1.5
version: 4.1.5(@types/node@25.6.0)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(yaml@2.8.3))
packages/skills: {}
packages/store:
@@ -174,6 +221,34 @@ importers:
specifier: ^4.1.5
version: 4.1.5(@types/node@22.19.17)(vite@8.0.9(@types/node@22.19.17)(esbuild@0.27.7)(yaml@2.8.3))
packages/workflow-meta:
dependencies:
'@uncaged/nerve-core':
specifier: workspace:*
version: link:../core
'@uncaged/nerve-role-committer':
specifier: workspace:*
version: link:../role-committer
'@uncaged/nerve-role-reviewer':
specifier: workspace:*
version: link:../role-reviewer
'@uncaged/nerve-workflow-utils':
specifier: workspace:*
version: link:../workflow-utils
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
'@rslib/core':
specifier: ^0.21.3
version: 0.21.3(typescript@5.9.3)
typescript:
specifier: ^5.8.3
version: 5.9.3
vitest:
specifier: ^4.1.5
version: 4.1.5(@types/node@25.6.0)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(yaml@2.8.3))
packages/workflow-utils:
dependencies:
'@uncaged/nerve-adapter-cursor':
@@ -1961,6 +2036,7 @@ packages:
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
vite@8.0.9: