Compare commits

..

6 Commits

Author SHA1 Message Date
xiaoju b9b804eac5 feat(core): sense trigger supports arbitrary shell commands
Extend SenseComputeReturn to support shell triggers in addition to workflow
triggers via a discriminated union (kind: 'shell' | 'workflow').

Shell triggers execute a command string in the sense worker subprocess
(spawned detached). The kernel logs 'shell-launch' events without involving
the workflow manager.

Breaking change: WorkflowTrigger now requires kind: 'workflow'.
New ShellTrigger type: { kind: 'shell', command: string }.
SenseTrigger = WorkflowTrigger | ShellTrigger.

Closes #315
2026-05-02 10:00:23 +00:00
xiaomo 6b8c917358 Merge pull request 'feat(cli): nerve agent inject claude — RFC #289 Phase 4' (#306) from feat/agent-inject-claude into main 2026-05-02 02:10:07 +00:00
tuanzi fd65acc329 feat(cli): nerve agent inject claude — inject to ~/.claude/CLAUDE.md
Add Claude Code support to nerve agent:
- nerve agent inject claude — append nerve skill block to ~/.claude/CLAUDE.md
- nerve agent remove claude — remove only the nerve block, preserve user content
- nerve agent status — show Claude Code injection status
- nerve agent update — includes Claude Code

Uses marker comments <!-- nerve-cli:start vX.Y.Z --> / <!-- nerve-cli:end -->
to safely coexist with user's existing CLAUDE.md content.

Closes #305
Ref: #289
2026-05-02 02:02:49 +00:00
xiaomo d442a64275 Merge pull request 'fix: harden state persistence (follow-up #313)' (#314) from fix/313-state-persistence-hardening into main 2026-05-01 12:03:31 +00:00
xiaoju 02c5e8bea6 fix(daemon): harden state persistence, ReadonlyArray triggers
1. writeState: atomic write via temp file + rename
2. readState: distinguish missing file vs corrupt JSON (warn on error)
3. executeCompute: write disk before updating memory state
4. SenseInfo.triggers: ReadonlyArray<string>
5. CLAUDE.md: added Sense State Persistence docs

Fixes #313
2026-05-01 12:01:50 +00:00
xiaomo eb7de9954f Merge pull request 'refactor: Stateful Sense (RFC #308)' (#312) from refactor/308-stateful-sense into main 2026-05-01 10:20:46 +00:00
13 changed files with 913 additions and 75 deletions
+17 -2
View File
@@ -28,6 +28,19 @@ External World → Sense(state) → { newState, workflow? } → Workflow → Log
### Sense State Persistence
Each sense's state is persisted as a JSON file at `data/senses/<name>.json` (relative to the nerve root, typically `~/.uncaged-nerve/`).
| Event | Behavior |
|-------|----------|
| **Worker start** | Read `state.json`; if missing or corrupt, use `initialState` from the sense module |
| **Compute success** | Write new state atomically (write-temp + rename), then update in-memory state |
| **Compute failure** | State unchanged (both disk and memory) |
| **Daemon restart** | State restored from last successful write |
State files are written atomically (temp file + rename) to prevent corruption on crash.
## Language & Paradigm
### Functional-first
@@ -91,10 +104,12 @@ type SenseConfig = {
For mutually exclusive fields, use discriminated unions:
```typescript
// ✅ Good — sense modules return explicit next state + optional workflow trigger
import type { SenseTrigger } from "@uncaged/nerve-core";
// ✅ Good — sense modules return explicit next state + optional trigger (workflow or shell)
type SenseComputeReturn<S> = {
state: S;
workflow: WorkflowTrigger | null;
workflow: SenseTrigger | null;
};
```
+553
View File
@@ -0,0 +1,553 @@
# Nerve — AI Agent 观测引擎
Nerve 是一个轻量级观测引擎守护进程。它持续观测外部状态,通过声明式规则响应变化,编排多步骤工作流。
## 核心架构
```
External World → Sense(state) → { newState, workflow? } → Workflow → Log
```
| 概念 | 说明 |
|------|------|
| **Sense** | 有状态的 `compute(state)` 函数。返回新状态和可选的 workflow trigger。状态以 JSON 文件持久化。调度由 nerve.yaml 配置。 |
| **Workflow** | 有状态的多步骤执行。包含 Role(有副作用的执行者)和 Moderator(纯路由器)。每个实例是一个 Thread,有唯一 runId。 |
| **Log** | 不可变审计日志。记录执行、状态转换、错误。不能触发 Sense(防止反馈循环)。 |
| **Engine** | 内核,持有 Process Manager、Workflow Manager、Sense Scheduler。不直接加载用户代码。 |
| **Daemon** | 引擎运行时,作为后台进程运行。 |
**关键规则:**
- 因果链单向:External → Sense(state) → Workflow(触发时) + Log
- 进程隔离:每个 Sense group 一个 worker(长期),每个 Workflow 类型一个 worker(按需)
- 两个扩展点:Sense(观测什么 + 何时)、Workflow(做什么)
## 工作区结构
`nerve init` 生成的工作区根目录(默认 `~/.uncaged-nerve/`)包含 **`AGENT.md`**。实现 sense/workflow 前先阅读该文件。
```
~/.uncaged-nerve/
├── AGENT.md # 人类 / Agent 可读的工作区约定(init 生成)
├── nerve.yaml # 核心配置
├── package.json # 单一根包(sense/workflow 下不再有独立 package)
├── scripts/build.mjs # 根目录 esbuild;通过 npm/pnpm 的 build 脚本调用
├── senses/
│ └── <name>/
│ └── src/index.ts # exports compute(state) + initialState
├── workflows/
│ └── <name>/
│ ├── index.ts # default export:WorkflowDefinition
│ ├── moderator.ts # 可选:抽出 moderator,由 index 导入
│ ├── build.ts # 可选:共享常量 / 纯函数(避免 index 臃肿;非 esbuild 入口)
│ └── roles/
│ └── <role>.ts # 每角色单文件(推荐平铺,而非 roles/<role>/index.ts)
└── data/
├── senses/ # sense 状态 JSON 文件(自动生成)
└── logs.db # 日志存储(自动生成)
```
### 命名约定
- **Workflow**:动词开头的 kebab-case(例如 `review-pull-request``deploy-staging`)。避免单独名词式命名(如 `notifications`)。
- **Sense**:描述性名词 kebab-case(例如 `cpu-usage`)。
---
## CLI 完整参考
全局选项:`--host <host:port>`(连接远程 daemon)、`--api-token <secret>`(Bearer 认证)
### 初始化与脚手架
```bash
nerve init # 初始化工作区
nerve init --from <git-url> # 从 git 仓库克隆工作区
nerve init workspace # 只初始化工作区结构
nerve create sense <name> # 创建 sense 脚手架
nerve create sense <name> --force # 覆盖已有
nerve create workflow <name> # 创建 workflow 脚手架
nerve create workflow <name> --force
nerve validate # 验证 nerve.yaml 配置
```
### Daemon 管理
```bash
nerve daemon start # 启动后台 daemon
nerve daemon start --port 3000 # 指定 HTTP API 端口
nerve daemon stop # 停止 daemon
nerve daemon restart # 重启
nerve daemon status # 查看状态
nerve daemon logs # 查看日志
nerve daemon logs --follow # 实时日志
nerve daemon logs --n 50 # 最近 50 行
nerve dev # 前台开发模式(不 fork daemon)
nerve dev --port 3000 # 指定端口
```
### Sense 操作
```bash
nerve sense list # 列出所有注册的 sense
nerve sense trigger <name> # 手动触发 sense 计算
```
### Workflow 操作
```bash
nerve workflow list # 列出 nerve.yaml 中定义的 workflow
nerve workflow status # 查看运行中的 workflow 状态
nerve workflow trigger <name> # 触发 workflow
nerve workflow trigger <name> --prompt "检查生产环境"
nerve workflow trigger <name> --maxRounds 50
nerve workflow trigger <name> --dryRun # 干跑模式
```
### Thread(Workflow 执行记录)
```bash
nerve thread list # 列出最近的 workflow 执行
nerve thread list --all # 包含已完成/失败的
nerve thread list --workflow <name> # 按 workflow 过滤
nerve thread list --limit 50 # 最多 50 条
nerve thread show <runId> # 查看 role 对话轮次
nerve thread show <runId> --budget 16000 # 增大输出预算(默认 8000 字符)
nerve thread inspect <runId> # 查看详情和事件
nerve thread kill <runId> # 终止运行中/排队中的 thread
```
### Store(日志归档)
```bash
nerve store archive # 导出旧日志到 JSONL 归档
nerve store archive --vacuum # 归档后 VACUUM 数据库
```
### Knowledge(知识库)
```bash
nerve knowledge sync # 从 knowledge.yaml 重建索引
nerve knowledge query "搜索内容" # 搜索知识库
nerve knowledge query "内容" --limit 5
nerve knowledge query "内容" -g # 搜索所有注册仓库
```
### Remote(远程 daemon)
```bash
nerve remote add <name> <host:port> --token <secret>
nerve remote list
nerve remote show <name>
nerve remote set-url <name> <host>
nerve remote set-token <name> <token>
nerve remote remove <name>
nerve remote default <name> # 设为默认远程
```
### Agent(向 AI Agent 注入 nerve skill)
```bash
nerve agent status # CLI 版本与各注入目录中的 skill 版本
nerve agent inject hermes # 安装到 ~/.hermes/skills/nerve
nerve agent inject hermes --profile <name> # 写入 ~/.hermes/profiles/<name>/skills/nerve
nerve agent inject cursor # 注入到当前项目 .cursorrules
nerve agent inject claude # 注入到 ~/.claude/CLAUDE.md
nerve agent update # 将所有已注入目录更新到当前 CLI 对应版本
nerve agent remove hermes # 移除 Hermes 注入
nerve agent remove cursor # 移除 Cursor 注入
nerve agent remove claude # 移除 Claude Code 注入
```
---
## nerve.yaml 配置参考
```yaml
# 引擎全局配置
max_rounds: 100 # moderator 最大轮次(默认 100)
# Sense 配置
senses:
cpu-usage:
group: system # 必填,同 group 的 sense 共享 worker
interval: 10s # 轮询间隔(duration: 5s, 10m, 1h)
throttle: 5s # 最小计算间隔
timeout: 10s # compute 超时
grace_period: null # 超时后优雅等待
system-health:
group: derived
on: [cpu-usage, disk-usage] # 响应式:被列出的 sense 完成 compute 时触发
throttle: null
timeout: null
# Workflow 配置
workflows:
my-workflow:
concurrency: 1 # 必填,并发数
overflow: drop # 必填,超并发时处理:drop | queue
max_queue: 100 # overflow=queue 时的队列上限(默认 100)
# HTTP API
api:
port: 3000 # null = 不启用 HTTP
host: "127.0.0.1" # 监听地址
token: null # 非 loopback 时必填
# LLM Extract(可选)
extract:
provider: anthropic
model: claude-sonnet-4-20250514
```
---
## Sense 开发指南
### compute 函数签名
Sense 的 `compute` 接收当前状态,返回新状态和可选的 workflow trigger。状态以 JSON 文件持久化在 `data/senses/<name>.json`
```typescript
import type { SenseComputeFn, WorkflowTrigger } from "@uncaged/nerve-core";
type MyState = {
lastRun: number | null;
count: number;
};
export const initialState: MyState = { lastRun: null, count: 0 };
export async function compute(state: MyState): Promise<{
state: MyState;
workflow: WorkflowTrigger | null;
}> {
return {
state: { lastRun: Date.now(), count: state.count + 1 },
workflow: null,
};
}
```
### 状态持久化
| 时机 | 行为 |
|------|------|
| Worker 启动 | 读取 `data/senses/<name>.json`;不存在或损坏则使用 `initialState` |
| Compute 成功 | 原子写入新状态(temp + rename),然后更新内存 |
| Compute 失败 | 状态不变(磁盘和内存都不变) |
| Daemon 重启 | 从上次成功写入恢复 |
### 返回值
```typescript
// workflow: null → 不触发 workflow
// workflow: WorkflowTrigger → 触发 workflow
type WorkflowTrigger = {
name: string; // workflow 名称(对应 nerve.yaml 中的 key)
maxRounds: number; // moderator 最大轮次
prompt: string; // 初始 prompt
dryRun: boolean; // 干跑模式
};
```
### Sense 模块导出
每个 sense 的 `src/index.ts` 必须导出两个东西:
```typescript
// senses/<name>/src/index.ts
// 1. 初始状态
export const initialState: MyState = { ... };
// 2. compute 函数
export async function compute(state: MyState): Promise<{
state: MyState;
workflow: WorkflowTrigger | null;
}> {
// ...
}
```
### 调度方式
1. **interval 轮询**`interval: 10s` — 每 10 秒执行一次
2. **响应式触发**`on: [cpu-usage]` — 当 cpu-usage 完成 compute 后触发
3. 两者可以组合
### 调试
```bash
nerve dev # 前台运行,看实时输出
nerve sense trigger <name> # 手动触发一次
```
### 完整示例:CPU 监控
```typescript
// senses/cpu-usage/src/index.ts
import { loadavg } from "node:os";
type CpuState = {
samples: Array<{ ts: number; value: number }>;
};
export const initialState: CpuState = { samples: [] };
export async function compute(state: CpuState): Promise<{
state: CpuState;
workflow: null;
}> {
const [oneMin] = loadavg();
const value = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0;
const newSamples = [...state.samples.slice(-99), { ts: Date.now(), value }];
return { state: { samples: newSamples }, workflow: null };
}
```
nerve.yaml:
```yaml
senses:
cpu-usage:
group: system
interval: 10s
throttle: 5s
timeout: 10s
```
---
## Workflow 开发指南
### 核心类型
```typescript
import type {
WorkflowDefinition,
RoleResult,
ThreadContext,
RoleMeta,
Moderator,
} from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
// Role<Meta> — (ctx: ThreadContext) => Promise<RoleResult<Meta>>
// RoleResult<Meta> — { content: string; meta: Meta }
// ThreadContext<M extends RoleMeta> — threadId, start(__start__ 帧), steps(各 role 轮次)
// Moderator<M> — (ctx) => 下一个 role 名 | END
// WorkflowDefinition<M extends RoleMeta> — name, roles, moderator
```
### createRole 四元组(接入 LLM 时推荐)
工作区根目录需安装 **`@uncaged/nerve-workflow-utils`**(及所选 agent 适配器包)。默认 `nerve init``package.json` 不含该依赖时,在 `~/.uncaged-nerve` 下执行 `pnpm add @uncaged/nerve-workflow-utils`(或 npm 等价命令)。
使用 **`createRole`**,按固定顺序传入四件事:
1. **adapter**`AgentFn``(ctx, systemPrompt) => Promise<string>`(原始模型输出文本)。
2. **prompt**`string`,或 `async (ctx: ThreadContext) => string`
3. **meta**`z.ZodType<M>`,供 moderator 路由的结构化 meta。
4. **extract**`{ provider: LlmProvider; dryRun: boolean | null }`,声明从回复中抽取 meta 时用的 LLM(OpenAI 兼容)及是否 dry-run。
```typescript
import { createLlmAdapter, createRole } from "@uncaged/nerve-workflow-utils";
import type { ThreadContext } from "@uncaged/nerve-core";
import { z } from "zod";
const provider = {
baseUrl: "https://api.example.com/v1",
apiKey: process.env.EXAMPLE_API_KEY!,
model: "gpt-4o-mini",
};
const planMeta = z.object({ next: z.enum(["execute", "stop"]) });
export const planner = createRole(
createLlmAdapter(provider),
async (ctx: ThreadContext) => `规划任务:${ctx.start.content}`,
planMeta,
{ provider, dryRun: null },
);
```
`createLlmAdapter` 仅位于 **`@uncaged/nerve-workflow-utils`**:用 `LlmProvider` 生成 `AgentFn`,单轮对话里 **system** 来自 `createRole` 解析后的 prompt 字符串,**user** 为线程起点 `ctx.start.content`
### 基本 Workflow 示例(平铺 `roles/<role>.ts`)
```typescript
// workflows/example/roles/main.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function main(ctx: ThreadContext): Promise<RoleResult<{ round: number }>> {
const prompt = ctx.start.content;
return {
content: `处理完成: ${prompt}`,
meta: { round: ctx.steps.length },
};
}
```
```typescript
// workflows/example/index.ts
import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { main } from "./roles/main.js";
type Meta = Record<"main", { round: number }>;
const workflow: WorkflowDefinition<Meta> = {
name: "example",
roles: { main },
moderator(ctx: ThreadContext<Meta>) {
return ctx.steps.length === 0 ? "main" : END;
},
};
export default workflow;
```
可选:将 `moderator` 挪到 `moderator.ts``import { route } from "./moderator.js"`,保持 `index.ts` 只负责组装 `WorkflowDefinition`
### 多 Role Workflow 示例
```typescript
// workflows/plan-execute-review/roles/planner.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function planner(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "计划: ...", meta: { status: "planned" } };
}
```
```typescript
// workflows/plan-execute-review/roles/executor.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function executor(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "执行: ...", meta: { status: "executed" } };
}
```
```typescript
// workflows/plan-execute-review/roles/reviewer.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function reviewer(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "审核通过", meta: { status: "approved" } };
}
```
```typescript
// workflows/plan-execute-review/index.ts
import type { WorkflowDefinition, ThreadContext } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { executor } from "./roles/executor.js";
import { planner } from "./roles/planner.js";
import { reviewer } from "./roles/reviewer.js";
type Roles = Record<"planner" | "executor" | "reviewer", { status: string }>;
const workflow: WorkflowDefinition<Roles> = {
name: "plan-execute-review",
roles: { planner, executor, reviewer },
moderator(ctx: ThreadContext<Roles>) {
if (ctx.steps.length === 0) return "planner";
const last = ctx.steps[ctx.steps.length - 1];
if (last.role === "planner") return "executor";
if (last.role === "executor") return "reviewer";
return END;
},
};
export default workflow;
```
### Agent 适配器
Workflow role 可以集成 AI agent。已知适配器 **ID**`echo``cursor``hermes``codex`
```typescript
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
```
没有现成 agent 包时,用 **`createLlmAdapter``@uncaged/nerve-workflow-utils`)** 从 OpenAI 兼容的 `LlmProvider` 构造 `AgentFn`,再交给 **`createRole`** 的四元组。
### Workflow 运行状态
`queued``started``completed` | `failed` | `crashed` | `killed` | `interrupted` | `dropped`
---
## 日常操作 Pattern
### 查看系统整体状态
```bash
nerve daemon status # daemon 是否在运行
nerve sense list # 所有 sense 及其调度配置
nerve workflow status # 运行中的 workflow
nerve thread list # 最近的 workflow 执行记录
```
### 手动触发 workflow
```bash
nerve workflow trigger my-workflow --prompt "手动检查"
nerve thread list --workflow my-workflow # 查看执行状态
nerve thread show <runId> # 查看对话详情
```
### 排查 sense 报错
```bash
nerve daemon logs --follow # 查看实时日志
nerve sense trigger <name> # 手动触发看报错
nerve dev # 前台模式,更详细的输出
```
### 开发新 sense
```bash
nerve create sense my-sensor # 脚手架
# 编辑 senses/my-sensor/src/index.ts — 实现 compute(state) + initialState
nerve validate # 验证配置
nerve dev # 前台测试
nerve sense trigger my-sensor # 单次触发验证
```
### 开发新 workflow
```bash
nerve create workflow my-flow # 脚手架
# 推荐对齐 AGENT.md:workflows/my-flow/index.ts + roles/<role>.ts(平铺),moderator 可拆到 moderator.ts
nerve validate # 验证配置
cd ~/.uncaged-nerve && npm run build # 工作区根目录构建;勿在单个 workflow 子目录单独跑 build
nerve workflow trigger my-flow --prompt "测试" --dryRun # 干跑
nerve thread show <runId> # 查看执行轨迹
```
---
## Pitfalls
- **Sense 状态**:`compute(state)` 必须返回 `{ state, workflow }` 对象。不要返回 null 或 undefined。
- **initialState**:每个 sense 必须导出 `initialState`,否则加载失败。
- **状态持久化**:daemon 自动管理状态读写,业务代码不要自行读写 state.json。
- **no optional properties**:nerve 代码规范禁止 `?:`,用 `T | null` 代替。
- **函数式风格**:用 `function` + `type`,不用 `class` + `interface`
- **workflow 用 default export**:工作区里通常只有 `workflows/<name>/index.ts` 使用 default export(daemon 加载约定)。
- **concurrency + overflow**:workflow 必须配置并发策略,否则验证失败。
- **moderator 是同步函数**:不要加 async,moderator 是纯路由逻辑,不能有副作用。
@@ -133,6 +133,7 @@ export async function compute(state) {
return {
state: { launched: true, idleTicks: state.idleTicks },
workflow: {
kind: "workflow",
name: "noop",
maxRounds: 3,
prompt: "e2e-archive",
+102 -3
View File
@@ -65,6 +65,10 @@ function writeVersionFile(skillDir: string, version: string): void {
const CURSOR_VERSION_MARKER_RE = /<!--\s*nerve-cli-version:\s*([^>]+?)\s*-->/;
const CLAUDE_BLOCK_START_RE = /<!--\s*nerve-cli:start\s+v([^\s>]+)\s*-->/;
const CLAUDE_BLOCK_END = "<!-- nerve-cli:end -->";
const CLAUDE_BLOCK_RE = /<!--\s*nerve-cli:start\s+v[^\s>]+\s*-->[\s\S]*?<!--\s*nerve-cli:end\s*-->/;
function resolveCursorProjectDir(pathArg: string | null): string {
const raw = pathArg !== null && pathArg !== "" ? pathArg : process.cwd();
return resolvePath(raw);
@@ -164,6 +168,82 @@ function removeHermes(profile: string | null): void {
process.stdout.write(`✅ Removed Hermes nerve skill${loc}\n`);
}
// --- Claude Code ---
function getClaudeGlobalFile(): string {
return join(homedir(), ".claude", "CLAUDE.md");
}
function readClaudeBlockVersion(): string | null {
const filePath = getClaudeGlobalFile();
if (!existsSync(filePath)) return null;
const content = readFileSync(filePath, "utf8");
const match = content.match(CLAUDE_BLOCK_START_RE);
return match !== null ? match[1].trim() : null;
}
function injectClaude(): void {
const templatePath = join(getSkillSourceDir(), "claude", "CLAUDE.md");
if (!existsSync(templatePath)) {
throw new Error("Cannot locate claude/CLAUDE.md template. Is the CLI package intact?");
}
const version = cliVersion();
const existingVer = readClaudeBlockVersion();
if (existingVer === version) {
process.stdout.write(`✅ Claude Code nerve skill is already up to date (v${version})\n`);
return;
}
const body = readFileSync(templatePath, "utf8");
const block = `<!-- nerve-cli:start v${version} -->\n${body.trim()}\n${CLAUDE_BLOCK_END}`;
const filePath = getClaudeGlobalFile();
mkdirSync(dirname(filePath), { recursive: true });
if (existsSync(filePath)) {
const existing = readFileSync(filePath, "utf8");
if (CLAUDE_BLOCK_RE.test(existing)) {
// Replace existing block
const updated = existing.replace(CLAUDE_BLOCK_RE, block);
writeFileSync(filePath, updated, "utf8");
} else {
// Append
const sep = existing.endsWith("\n") ? "\n" : "\n\n";
writeFileSync(filePath, `${existing}${sep}${block}\n`, "utf8");
}
} else {
writeFileSync(filePath, `${block}\n`, "utf8");
}
const action = existingVer !== null ? "Updated" : "Installed";
process.stdout.write(`${action} Claude Code nerve skill v${version}\n`);
process.stdout.write(`${filePath}\n`);
}
function removeClaude(): void {
const filePath = getClaudeGlobalFile();
if (!existsSync(filePath)) {
process.stdout.write("ℹ️ Claude Code nerve skill is not installed.\n");
return;
}
const content = readFileSync(filePath, "utf8");
if (!CLAUDE_BLOCK_RE.test(content)) {
process.stdout.write("ℹ️ Claude Code nerve skill is not installed.\n");
return;
}
const cleaned = content
.replace(CLAUDE_BLOCK_RE, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (cleaned.length === 0) {
rmSync(filePath, { force: true });
} else {
writeFileSync(filePath, `${cleaned}\n`, "utf8");
}
process.stdout.write("✅ Removed Claude Code nerve skill\n");
}
function printCursorStatusLine(projectDir: string): void {
const rulesPath = join(projectDir, ".cursorrules");
const label = `Cursor (${projectDir})`;
@@ -193,6 +273,11 @@ function printStatus(): void {
printCursorStatusLine(process.cwd());
process.stdout.write("\n");
// Claude Code
const claudeVer = readClaudeBlockVersion();
printAgentLine("Claude Code", claudeVer);
process.stdout.write("\n");
// Default profile
const defaultDir = getHermesSkillDir(null);
const defaultVer = readVersionFile(defaultDir);
@@ -237,7 +322,7 @@ const injectCommand = defineCommand({
args: {
target: {
type: "positional",
description: "Agent target: hermes | cursor",
description: "Agent target: hermes | cursor | claude",
},
profile: {
type: "string",
@@ -267,8 +352,12 @@ const injectCommand = defineCommand({
injectCursor(resolveCursorProjectDir(pathArg));
return;
}
if (target === "claude") {
injectClaude();
return;
}
process.stderr.write(`❌ Unknown agent target: ${target}\n`);
process.stderr.write(" Supported targets: hermes, cursor\n");
process.stderr.write(" Supported targets: hermes, cursor, claude\n");
process.exit(1);
},
});
@@ -307,6 +396,12 @@ const updateCommand = defineCommand({
if (updated === 0) {
process.stdout.write("ℹ️ No injected skills found. Run `nerve agent inject hermes` first.\n");
}
// Claude Code (always check, no profiles)
const claudeVer = readClaudeBlockVersion();
if (claudeVer !== null) {
injectClaude();
}
},
});
@@ -348,8 +443,12 @@ const removeCommand = defineCommand({
removeCursor(resolveCursorProjectDir(pathArg));
return;
}
if (target === "claude") {
removeClaude();
return;
}
process.stderr.write(`❌ Unknown agent target: ${target}\n`);
process.stderr.write(" Supported targets: hermes, cursor\n");
process.stderr.write(" Supported targets: hermes, cursor, claude\n");
process.exit(1);
},
});
@@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest";
import { parseWorkflowTrigger } from "../sense.js";
import { parseSenseTrigger } from "../sense.js";
describe("parseWorkflowTrigger", () => {
it("accepts a valid trigger object", () => {
const r = parseWorkflowTrigger({
describe("parseSenseTrigger", () => {
it("accepts a valid workflow trigger", () => {
const r = parseSenseTrigger({
kind: "workflow",
name: "my-wf",
maxRounds: 3,
prompt: "go",
@@ -12,11 +13,18 @@ describe("parseWorkflowTrigger", () => {
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value).toEqual({ name: "my-wf", maxRounds: 3, prompt: "go", dryRun: true });
expect(r.value).toEqual({
kind: "workflow",
name: "my-wf",
maxRounds: 3,
prompt: "go",
dryRun: true,
});
});
it("trims workflow name", () => {
const r = parseWorkflowTrigger({
const r = parseSenseTrigger({
kind: "workflow",
name: " spaced ",
maxRounds: 1,
prompt: "",
@@ -24,16 +32,45 @@ describe("parseWorkflowTrigger", () => {
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.kind).toBe("workflow");
if (r.value.kind !== "workflow") return;
expect(r.value.name).toBe("spaced");
});
it("rejects empty name", () => {
const r = parseWorkflowTrigger({ name: "", maxRounds: 1, prompt: "x", dryRun: false });
it("accepts a valid shell trigger", () => {
const r = parseSenseTrigger({
kind: "shell",
command: " echo hi ",
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value).toEqual({ kind: "shell", command: "echo hi" });
});
it("rejects workflow without kind", () => {
const r = parseSenseTrigger({
name: "my-wf",
maxRounds: 1,
prompt: "x",
dryRun: false,
});
expect(r.ok).toBe(false);
});
it("rejects empty workflow name", () => {
const r = parseSenseTrigger({
kind: "workflow",
name: "",
maxRounds: 1,
prompt: "x",
dryRun: false,
});
expect(r.ok).toBe(false);
});
it("rejects non-integer maxRounds", () => {
const r = parseWorkflowTrigger({
const r = parseSenseTrigger({
kind: "workflow",
name: "w",
maxRounds: 1.5,
prompt: "",
@@ -43,12 +80,19 @@ describe("parseWorkflowTrigger", () => {
});
it("rejects maxRounds < 1", () => {
const r = parseWorkflowTrigger({ name: "w", maxRounds: 0, prompt: "", dryRun: false });
const r = parseSenseTrigger({
kind: "workflow",
name: "w",
maxRounds: 0,
prompt: "",
dryRun: false,
});
expect(r.ok).toBe(false);
});
it("rejects non-boolean dryRun", () => {
const r = parseWorkflowTrigger({
const r = parseSenseTrigger({
kind: "workflow",
name: "w",
maxRounds: 1,
prompt: "",
@@ -56,4 +100,14 @@ describe("parseWorkflowTrigger", () => {
});
expect(r.ok).toBe(false);
});
it("rejects empty shell command", () => {
const r = parseSenseTrigger({ kind: "shell", command: "" });
expect(r.ok).toBe(false);
});
it("rejects unknown kind", () => {
const r = parseSenseTrigger({ kind: "other", x: 1 });
expect(r.ok).toBe(false);
});
});
+10
View File
@@ -54,12 +54,22 @@ export type ExtractConfig = {
/** Parameters for starting a workflow from a Sense compute result (or CLI trigger). */
export type WorkflowTrigger = {
kind: "workflow";
name: string;
maxRounds: number;
prompt: string;
dryRun: boolean;
};
/** Run a shell command from a Sense compute result (daemon executes in the sense worker). */
export type ShellTrigger = {
kind: "shell";
command: string;
};
/** Optional side effect requested by `compute()` — workflow launch or shell command. */
export type SenseTrigger = WorkflowTrigger | ShellTrigger;
export type NerveConfig = {
/** Engine-wide default max moderator rounds (e.g. CLI workflow trigger when omitted). */
maxRounds: number;
+3 -1
View File
@@ -8,6 +8,8 @@ export type {
ExtractConfig,
NerveConfig,
WorkflowTrigger,
ShellTrigger,
SenseTrigger,
} from "./config.js";
export type { SenseInfo } from "./sense.js";
export type { SenseComputeFn, SenseModule } from "./sense.js";
@@ -44,7 +46,7 @@ export type { KnowledgeConfig } from "./config.js";
export { parseKnowledgeYaml } from "./config.js";
export { isPlainRecord } from "./util.js";
export { parseWorkflowTrigger } from "./sense.js";
export { parseSenseTrigger } from "./sense.js";
export { isSenseInfo, isWorkflowStatus } from "./daemon.js";
export type {
+37 -12
View File
@@ -1,4 +1,4 @@
import type { SenseConfig, WorkflowTrigger } from "./config.js";
import type { SenseConfig, SenseTrigger, ShellTrigger, WorkflowTrigger } from "./config.js";
import { type Result, err, isPlainRecord, ok } from "./util.js";
/** Runtime metadata for a sense (e.g. daemon list-senses IPC). */
@@ -8,7 +8,7 @@ export type SenseInfo = {
throttle: number | null;
timeout: number | null;
/** Declarative schedule (`interval` / `on`) for this sense (derived from nerve.yaml). */
triggers: string[];
triggers: ReadonlyArray<string>;
};
/**
@@ -16,11 +16,11 @@ export type SenseInfo = {
* `compute` export.
*
* Pure: no DB, no peers.
* Returns the next sense state and an optional workflow to start (`workflow: null` means no workflow).
* Returns the next sense state and an optional trigger (`workflow: null` means no side effect).
*/
export type SenseComputeFn<S = unknown> = (
state: S,
) => Promise<{ state: S; workflow: WorkflowTrigger | null }>;
) => Promise<{ state: S; workflow: SenseTrigger | null }>;
/**
* The full shape a sense module (`src/index.ts`) must export.
@@ -69,13 +69,7 @@ export function senseTriggerLabels(
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"));
}
function parseWorkflowTriggerBranch(value: Record<string, unknown>): Result<WorkflowTrigger> {
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'));
@@ -92,5 +86,36 @@ export function parseWorkflowTrigger(value: unknown): Result<WorkflowTrigger> {
if (typeof dryRun !== "boolean") {
return err(new Error('workflow trigger: "dryRun" must be a boolean'));
}
return ok({ name: nameRaw.trim(), maxRounds, prompt, dryRun });
return ok({
kind: "workflow",
name: nameRaw.trim(),
maxRounds,
prompt,
dryRun,
});
}
function parseShellTriggerBranch(value: Record<string, unknown>): Result<ShellTrigger> {
const command = value.command;
if (typeof command !== "string" || command.trim().length === 0) {
return err(new Error('shell trigger: "command" must be a non-empty string'));
}
return ok({ kind: "shell", command: command.trim() });
}
/**
* Validates a structured sense trigger from Sense compute or IPC (`workflow` field).
*/
export function parseSenseTrigger(value: unknown): Result<SenseTrigger> {
if (!isPlainRecord(value)) {
return err(new Error("sense trigger must be a plain object"));
}
const kind = value.kind;
if (kind === "workflow") {
return parseWorkflowTriggerBranch(value);
}
if (kind === "shell") {
return parseShellTriggerBranch(value);
}
return err(new Error('sense trigger: "kind" must be "workflow" or "shell"'));
}
@@ -184,6 +184,7 @@ describe("kernel + workflowManager integration", () => {
sense: "cpu-usage",
state: { reason: "test" },
workflow: {
kind: "workflow",
name: "my-workflow",
maxRounds: 10,
prompt: "run this workflow",
@@ -240,6 +241,7 @@ describe("kernel + workflowManager integration", () => {
sense: "cpu-usage",
state: { level: "critical" },
workflow: {
kind: "workflow",
name: "alert-workflow",
maxRounds: 5,
prompt: "handle critical alert",
@@ -294,6 +296,7 @@ describe("kernel + workflowManager integration", () => {
sense: "cpu-usage",
state: { seq: 1 },
workflow: {
kind: "workflow",
name: "order-wf",
maxRounds: 2,
prompt: "p",
@@ -374,6 +377,51 @@ describe("kernel + workflowManager integration", () => {
await vi.runAllTimersAsync();
await stopPromise;
});
it("logs shell-launch and does not start a workflow for shell triggers", async () => {
const logStore = makeLogStore();
const config = makeConfig({ workflows: {} });
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
await flushSenseWorkerForkMicrotasks(kernel);
await vi.runAllTimersAsync();
const workerPool = mockChildren[0];
if (workerPool) {
workerPool.emit("message", {
type: "compute-result",
sense: "cpu-usage",
state: {},
workflow: {
kind: "shell",
command: "echo nerve-shell-test",
},
});
}
await vi.runAllTimersAsync();
const shellLaunch = logStore.append.mock.calls
.map((c) => c[0] as { source: string; type: string })
.find((e) => e.type === "shell-launch");
expect(shellLaunch).toBeDefined();
const startThread = mockChildren
.flatMap((c) => (c.send as ReturnType<typeof vi.fn>).mock.calls as [unknown][])
.some(
([msg]) =>
msg !== null &&
typeof msg === "object" &&
(msg as Record<string, unknown>).type === "start-thread",
);
expect(startThread).toBe(false);
const stopPromise = kernel.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
});
describe("workflow events are logged", () => {
@@ -407,6 +455,7 @@ describe("kernel + workflowManager integration", () => {
sense: "cpu-usage",
state: { note: "log" },
workflow: {
kind: "workflow",
name: "log-test-workflow",
maxRounds: 10,
prompt: "test prompt",
@@ -479,6 +528,7 @@ describe("kernel + workflowManager integration", () => {
sense: "cpu-usage",
state: { phase: "reload" },
workflow: {
kind: "workflow",
name: "new-workflow",
maxRounds: 10,
prompt: "reload test",
@@ -560,6 +610,7 @@ describe("kernel + workflowManager integration", () => {
sense: "cpu-usage",
state: { stale: true },
workflow: {
kind: "workflow",
name: "old-workflow",
maxRounds: 10,
prompt: "should not work",
@@ -618,6 +669,7 @@ describe("kernel + workflowManager integration", () => {
sense: "cpu-usage",
state: { shutdownCase: true },
workflow: {
kind: "workflow",
name: "shutdown-test",
maxRounds: 10,
prompt: "test",
+11 -20
View File
@@ -3,8 +3,8 @@
* Protocol per RFC §5.2: hub-and-spoke, all messages through engine.
*/
import type { Result, WorkflowTrigger } from "@uncaged/nerve-core";
import { err, isPlainRecord, ok, parseWorkflowTrigger } from "@uncaged/nerve-core";
import type { Result, SenseTrigger, WorkflowTrigger } from "@uncaged/nerve-core";
import { err, isPlainRecord, ok, parseSenseTrigger } from "@uncaged/nerve-core";
/** Parent → Worker: trigger one compute cycle for a sense */
export type ComputeMessage = {
@@ -70,7 +70,7 @@ export type ComputeResultMessage = {
type: "compute-result";
sense: string;
state: unknown;
workflow: WorkflowTrigger | null;
workflow: SenseTrigger | null;
};
/** Worker → Parent: sense compute result includes a workflow to start */
@@ -262,11 +262,11 @@ function parseComputeResultMsg(obj: Record<string, unknown>): Result<WorkerToPar
if (wfRaw !== null && !isPlainRecord(wfRaw)) {
return err(new Error("Worker 'compute-result' workflow must be an object or null"));
}
let workflow: WorkflowTrigger | null;
let workflow: SenseTrigger | null;
if (wfRaw === null) {
workflow = null;
} else {
const parsed = parseWorkflowTrigger(wfRaw);
const parsed = parseSenseTrigger(wfRaw);
if (!parsed.ok) return err(parsed.error);
workflow = parsed.value;
}
@@ -412,24 +412,15 @@ function parseSenseWorkflowTriggerMsg(obj: Record<string, unknown>): Result<Work
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'"));
const parsed = parseSenseTrigger(obj.workflow);
if (!parsed.ok) return err(parsed.error);
if (parsed.value.kind !== "workflow") {
return err(new Error("Worker 'sense-workflow-trigger' expects kind \"workflow\""));
}
return ok({
type: "sense-workflow-trigger",
sense: obj.sense,
workflow: {
name: wf.name,
maxRounds: wf.maxRounds,
prompt: wf.prompt,
dryRun: wf.dryRun,
},
workflow: parsed.value,
});
}
+24 -14
View File
@@ -12,7 +12,7 @@ import {
type HealthInfo,
type NerveConfig,
type SenseInfo,
type WorkflowTrigger,
type SenseTrigger,
senseTriggerLabels,
} from "@uncaged/nerve-core";
@@ -145,7 +145,7 @@ export function createKernel(
}
}
function handleComputeResult(senseName: string, workflow: WorkflowTrigger | null): void {
function handleComputeResult(senseName: string, workflow: SenseTrigger | null): void {
logStore.append({
source: "sense",
type: "compute-complete",
@@ -155,18 +155,28 @@ export function createKernel(
});
if (workflow !== null) {
workflowManager.startWorkflow(workflow.name, {
prompt: workflow.prompt,
maxRounds: workflow.maxRounds,
dryRun: workflow.dryRun,
});
logStore.append({
source: "sense",
type: "workflow-launch",
refId: senseName,
payload: JSON.stringify(workflow),
timestamp: Date.now(),
});
if (workflow.kind === "workflow") {
workflowManager.startWorkflow(workflow.name, {
prompt: workflow.prompt,
maxRounds: workflow.maxRounds,
dryRun: workflow.dryRun,
});
logStore.append({
source: "sense",
type: "workflow-launch",
refId: senseName,
payload: JSON.stringify(workflow),
timestamp: Date.now(),
});
} else {
logStore.append({
source: "sense",
type: "shell-launch",
refId: senseName,
payload: JSON.stringify(workflow),
timestamp: Date.now(),
});
}
}
scheduler.onComputeComplete(senseName);
scheduler.onSenseCompleted(senseName);
+16 -8
View File
@@ -1,7 +1,7 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import type { Result, SenseComputeFn, WorkflowTrigger } from "@uncaged/nerve-core";
import type { Result, SenseComputeFn, SenseTrigger } from "@uncaged/nerve-core";
import { err, isPlainRecord, ok } from "@uncaged/nerve-core";
/** All state held for one sense inside a worker */
@@ -14,16 +14,24 @@ export type SenseRuntime = {
export function readState(statePath: string, initialState: unknown): unknown {
try {
if (!existsSync(statePath)) return initialState;
const raw = readFileSync(statePath, "utf8");
return JSON.parse(raw);
} catch {
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(
`[sense-runtime] warning: failed to read state from "${statePath}": ${msg} — using initialState\n`,
);
return initialState;
}
}
export function writeState(statePath: string, state: unknown): void {
mkdirSync(dirname(statePath), { recursive: true });
writeFileSync(statePath, JSON.stringify(state, null, 2));
const dir = dirname(statePath);
mkdirSync(dir, { recursive: true });
const tmp = join(dir, `.${Date.now()}.tmp`);
writeFileSync(tmp, JSON.stringify(state, null, 2));
renameSync(tmp, statePath);
}
/**
@@ -66,7 +74,7 @@ export async function loadSenseModule(
export async function executeCompute(
runtime: SenseRuntime,
timeoutMs?: number,
): Promise<Result<{ state: unknown; workflow: WorkflowTrigger | null }>> {
): Promise<Result<{ state: unknown; workflow: SenseTrigger | null }>> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
@@ -86,8 +94,8 @@ export async function executeCompute(
? await Promise.race([computePromise, timeoutPromise])
: await computePromise;
runtime.state = result.state;
writeState(runtime.statePath, result.state);
runtime.state = result.state;
return ok(result);
} catch (e) {
+22 -4
View File
@@ -14,11 +14,12 @@
import "./experimental-warning-suppression.js";
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { parseNerveConfig } from "@uncaged/nerve-core";
import type { NerveConfig, WorkflowTrigger } from "@uncaged/nerve-core";
import type { NerveConfig, SenseTrigger } from "@uncaged/nerve-core";
import type { WorkerToParentMessage } from "./ipc.js";
import { parseParentMessage } from "./ipc.js";
@@ -42,11 +43,25 @@ function sendReady(): void {
function sendComputeResult(
sense: string,
value: { state: unknown; workflow: WorkflowTrigger | null },
value: { state: unknown; workflow: SenseTrigger | null },
): void {
send({ type: "compute-result", sense, state: value.state, workflow: value.workflow });
}
function executeShellTriggerIfNeeded(nerveRoot: string, trigger: SenseTrigger | null): void {
if (trigger === null || trigger.kind !== "shell") return;
const child = spawn(trigger.command, {
shell: true,
cwd: nerveRoot,
detached: true,
stdio: "ignore",
});
child.on("error", (err) => {
process.stderr.write(`[sense-worker] shell trigger failed: ${err.message}\n`);
});
child.unref();
}
function sendError(sense: string, error: string): void {
send({ type: "error", sense, error });
}
@@ -132,6 +147,7 @@ async function runCompute(
runtime: SenseRuntime,
timeoutMs: number,
gracePeriodMs: number | null,
nerveRoot: string,
): Promise<void> {
try {
const result = await executeCompute(runtime, timeoutMs);
@@ -143,6 +159,7 @@ async function runCompute(
return;
}
clearGracePeriodTimer(senseName);
executeShellTriggerIfNeeded(nerveRoot, result.value.workflow);
sendComputeResult(senseName, result.value);
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
@@ -160,6 +177,7 @@ function handleMessage(
group: string,
senseConfigs: Map<string, { timeout: number | null; gracePeriod: number | null }>,
inFlight: Map<string, Promise<void>>,
nerveRoot: string,
): void {
const parseResult = parseParentMessage(raw);
if (!parseResult.ok) {
@@ -196,7 +214,7 @@ function handleMessage(
const previous = inFlight.get(msg.sense) ?? Promise.resolve();
const next = previous
.then(() => runCompute(msg.sense, runtime, timeoutMs, gracePeriodMs))
.then(() => runCompute(msg.sense, runtime, timeoutMs, gracePeriodMs, nerveRoot))
.catch((e: unknown) => {
const errMsg = e instanceof Error ? e.message : String(e);
sendError(msg.sense, errMsg);
@@ -257,7 +275,7 @@ async function bootstrap(nerveRoot: string, group: string): Promise<void> {
sendReady();
process.on("message", (raw: unknown) => {
handleMessage(raw, runtimes, group, senseConfigs, inFlight);
handleMessage(raw, runtimes, group, senseConfigs, inFlight, nerveRoot);
});
}