Compare commits

...

15 Commits

Author SHA1 Message Date
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
40 changed files with 1606 additions and 285 deletions
+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
+8 -2
View File
@@ -84,16 +84,22 @@ 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> => {
const run = await cursorAgent({
prompt,
mode: "default",
mode,
model: config.model,
cwd: context.workdir,
env: null,
@@ -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 },
@@ -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
@@ -27,7 +27,6 @@ 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";
+1 -1
View File
@@ -330,7 +330,7 @@ export function parseNerveConfig(raw: string): Result<NerveConfig> {
if (Object.hasOwn(obj, "agents")) {
return err(
new Error(
"agents: key is no longer supported — declare adapters on WorkflowSpec roles (RFC-003)",
"agents: key is no longer supported — declare adapters on workflow roles (RFC-003)",
),
);
}
-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>;
};
@@ -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");
});
});
@@ -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
View File
@@ -58,6 +58,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";
+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,
},
});
+55
View File
@@ -0,0 +1,55 @@
import type { AgentFn, Role, StartStep } 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 (start: StartStep) => committerPrompt(start.meta.threadId),
committerMetaSchema,
extract,
);
return decorateRole(inner, [
withDryRun({ label: "committer", meta: { committed: true } as CommitterMeta }),
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, StartStep } 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 (start: StartStep) => reviewerPrompt({ threadId: start.meta.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, StartStep } 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 (start: StartStep) => coderPrompt({ threadId: start.meta.threadId }),
coderMetaSchema,
extract,
);
}
@@ -0,0 +1,39 @@
import type { AgentFn, Role, StartStep } 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 (start: StartStep) => plannerPrompt({ threadId: start.meta.threadId }),
plannerMetaSchema,
extract,
);
}
@@ -0,0 +1,60 @@
import type { AgentFn, Role, StartStep } 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 (start: StartStep) => testerPrompt({ threadId: start.meta.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, StartStep } 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 (start: StartStep) => coderPrompt({ threadId: start.meta.threadId }),
coderMetaSchema,
extract,
);
}
@@ -0,0 +1,68 @@
import type { AgentFn, Role, StartStep } 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 (start: StartStep) => plannerPrompt({ threadId: start.meta.threadId }),
plannerMetaSchema,
extract,
);
}
@@ -0,0 +1,61 @@
import type { AgentFn, Role, StartStep } 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 (start: StartStep) => testerPrompt({ threadId: start.meta.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,180 @@
import type {
AgentFn,
ModeratorContext,
RoleMeta,
WorkflowContext,
WorkflowDefinition,
WorkflowMessage,
} 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; dryRun: boolean; threadId: string };
timestamp: number;
} {
return {
role: START,
content: "",
meta: { maxRounds: 10, dryRun: false, threadId },
timestamp: Date.now(),
};
}
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 (prompt) => prompt;
const role = createRole(adapter, "hello", schema, { provider });
const out = await role(makeStart("t1"), []);
expect(out.content).toBe("hello");
expect(out.meta).toEqual({ n: 3 });
});
it("passes WorkflowContext with workdir defaulting to process.cwd()", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 0 }))));
const seen: WorkflowContext[] = [];
const adapter: AgentFn = async (_prompt, ctx) => {
seen.push(ctx);
return "x";
};
const role = createRole(adapter, "p", z.object({ n: z.number() }), { provider });
await role(makeStart("t1"), []);
expect(seen).toHaveLength(1);
expect(seen[0].workdir).toBe(process.cwd());
});
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 (prompt) => prompt;
const role = createRole(
adapter,
async (start, messages) => `tid=${start.meta.threadId} n=${messages.length}`,
schema,
{ provider },
);
const start = makeStart("thread-x");
const msgs: WorkflowMessage[] = [{ role: "a", content: "m", meta: {}, timestamp: 1 }];
const out = await role(start, msgs);
expect(out.content).toBe("tid=thread-x n=1");
expect(out.meta).toEqual({ n: 99 });
});
it("uses start.meta.dryRun when extract.dryRun is omitted", 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 });
const start = {
role: START,
content: "",
meta: { maxRounds: 10, dryRun: true, threadId: "x" },
timestamp: 1,
};
await role(start, []);
expect(spy).toHaveBeenCalledWith(
"raw",
expect.anything(),
expect.objectContaining({ provider, dryRun: true }),
);
});
it("prefers extract.dryRun over start.meta.dryRun", 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: false,
});
const start = {
role: START,
content: "",
meta: { maxRounds: 10, dryRun: true, threadId: "x" },
timestamp: 1,
};
await role(start, []);
expect(spy).toHaveBeenCalledWith(
"raw",
expect.anything(),
expect.objectContaining({ dryRun: false }),
);
});
});
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 (_start, _messages) => ({
content: "hi",
meta: { id: "a" },
}),
},
moderator: (_ctx: ModeratorContext<M>) => END,
};
const start = makeStart("t1");
const out = await manual.roles.legacy(start, []);
expect(out.content).toBe("hi");
expect(out.meta.id).toBe("a");
});
});
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import type { Role, StartStep } 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 fakeStart(dryRun: boolean): StartStep {
return {
role: START,
content: "",
meta: {
threadId: "t1",
dryRun,
maxRounds: 10,
},
timestamp: Date.now(),
};
}
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 } });
it("short-circuits on dry-run", async () => {
const role = dec(successRole);
const result = await role(fakeStart(true), []);
expect(result.content).toBe("[dry-run] test skipped");
expect(result.meta).toEqual({ ok: true });
});
it("delegates when not dry-run", async () => {
const role = dec(successRole);
const result = await role(fakeStart(false), []);
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(fakeStart(false), []);
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(fakeStart(false), []);
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(fakeStart(false), []);
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({ label: "x", meta: { ok: true } }),
onFail({ label: "x", meta: { ok: false } }),
]);
// Not dry-run, so withDryRun passes through → failRole throws → onFail catches
const result = await role(fakeStart(false), []);
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({ label: "x", meta: { ok: true } }),
onFail({ label: "x", meta: { ok: false } }),
]);
const result = await role(fakeStart(true), []);
expect(result.content).toBe("[dry-run] x skipped");
expect(result.meta).toEqual({ ok: true });
});
});
@@ -0,0 +1,55 @@
import type {
AgentFn,
Role,
StartStep,
WorkflowContext,
WorkflowMessage,
} 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 | ((start: StartStep, messages: WorkflowMessage[]) => Promise<string>);
export type LlmExtractorConfig = {
provider: LlmProvider;
/** When omitted, uses `start.meta.dryRun` at runtime. */
dryRun?: boolean;
};
type StartMetaWithWorkdir = StartStep["meta"] & { workdir?: string | null };
function resolveWorkdir(start: StartStep): string {
const m = start.meta as StartMetaWithWorkdir;
return m.workdir ?? process.cwd();
}
function resolveDryRun(extract: LlmExtractorConfig, start: StartStep): boolean {
return extract.dryRun ?? start.meta.dryRun;
}
/** 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 (start: StartStep, messages: WorkflowMessage[]) => {
const ctx: WorkflowContext = {
start,
messages,
workdir: resolveWorkdir(start),
signal: new AbortController().signal,
};
const promptText = typeof prompt === "string" ? prompt : await prompt(start, messages);
const raw = await adapter(promptText, ctx);
const result = await extractMetaOrThrow(raw, meta, {
provider: extract.provider,
dryRun: resolveDryRun(extract, start),
});
return { content: raw, meta: result };
};
}
+10
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 {
@@ -18,6 +20,14 @@ export {
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,
@@ -0,0 +1,90 @@
import type { Role, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
import { isDryRun } from "./role-types.js";
// ---------------------------------------------------------------------------
// 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;
};
/**
* Returns a decorator that short-circuits with a stable result when
* `start.meta.dryRun` is true.
*/
export function withDryRun<M extends Record<string, unknown>>(
opts: WithDryRunOptions<M>,
): RoleDecorator<M> {
return (role) => async (start: StartStep, messages: WorkflowMessage[]) => {
if (isDryRun(start)) {
return {
content: `[dry-run] ${opts.label} skipped`,
meta: opts.meta,
};
}
return role(start, messages);
};
}
// ---------------------------------------------------------------------------
// 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 (start: StartStep, messages: WorkflowMessage[]) => {
try {
return await role(start, messages);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
content: `${opts.label} failed: ${msg}`,
meta: opts.meta,
};
}
};
}
@@ -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)) {
+72
View File
@@ -156,6 +156,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 +218,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':