From 4a4a03a2bc161bb36105629831c2670e87a5e781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 29 Apr 2026 12:57:33 +0000 Subject: [PATCH 1/3] feat(workflow-utils): add withDryRun role wrapper Extracts repeated dry-run skip + try/catch error handling into a reusable wrapper. Includes tests. Closes #254 --- .../src/__tests__/with-dry-run.test.ts | 81 +++++++++++++++++++ packages/workflow-utils/src/index.ts | 1 + packages/workflow-utils/src/with-dry-run.ts | 39 +++++++++ 3 files changed, 121 insertions(+) create mode 100644 packages/workflow-utils/src/__tests__/with-dry-run.test.ts create mode 100644 packages/workflow-utils/src/with-dry-run.ts diff --git a/packages/workflow-utils/src/__tests__/with-dry-run.test.ts b/packages/workflow-utils/src/__tests__/with-dry-run.test.ts new file mode 100644 index 0000000..9e9eebb --- /dev/null +++ b/packages/workflow-utils/src/__tests__/with-dry-run.test.ts @@ -0,0 +1,81 @@ +import type { Role, WorkflowMessage } from "@uncaged/nerve-core"; +import { START } from "@uncaged/nerve-core"; +import { describe, expect, it } from "vitest"; + +import { withDryRun } from "../with-dry-run.js"; + +function makeStart(dryRun: boolean, threadId = "t1") { + return { + role: START, + content: "", + meta: { maxRounds: 10, dryRun, threadId }, + timestamp: Date.now(), + }; +} + +describe("withDryRun", () => { + it("returns dry-run message and meta when start.meta.dryRun is true", async () => { + const inner: Role<{ committed: boolean }> = async () => ({ + content: "should not run", + meta: { committed: true }, + }); + const wrapped = withDryRun(inner, { + label: "committer", + dryRunMeta: { committed: true }, + failMeta: { committed: false }, + }); + + const out = await wrapped(makeStart(true), []); + expect(out).toEqual({ + content: "[dry-run] committer skipped", + meta: { committed: true }, + }); + }); + + it("delegates to inner role when not dry-run", async () => { + const inner: Role<{ ok: boolean }> = async (_start, messages) => ({ + content: `n=${messages.length}`, + meta: { ok: true }, + }); + const wrapped = withDryRun(inner, { + label: "publish", + dryRunMeta: { ok: false }, + failMeta: { ok: false }, + }); + + const msgs: WorkflowMessage[] = [{ role: "a", content: "x", meta: {}, timestamp: 1 }]; + const out = await wrapped(makeStart(false), msgs); + expect(out.content).toBe("n=1"); + expect(out.meta).toEqual({ ok: true }); + }); + + it("maps thrown Error to fail content and failMeta", async () => { + const inner: Role<{ committed: boolean }> = async () => { + throw new Error("disk full"); + }; + const wrapped = withDryRun(inner, { + label: "committer", + dryRunMeta: { committed: true }, + failMeta: { committed: false }, + }); + + const out = await wrapped(makeStart(false), []); + expect(out.content).toBe("committer failed: disk full"); + expect(out.meta).toEqual({ committed: false }); + }); + + it("maps non-Error throw to fail content and failMeta", async () => { + const inner: Role<{ committed: boolean }> = async () => { + throw 404; + }; + const wrapped = withDryRun(inner, { + label: "committer", + dryRunMeta: { committed: true }, + failMeta: { committed: false }, + }); + + const out = await wrapped(makeStart(false), []); + expect(out.content).toBe("committer failed: 404"); + expect(out.meta).toEqual({ committed: false }); + }); +}); diff --git a/packages/workflow-utils/src/index.ts b/packages/workflow-utils/src/index.ts index 376b25f..f9ff5af 100644 --- a/packages/workflow-utils/src/index.ts +++ b/packages/workflow-utils/src/index.ts @@ -20,6 +20,7 @@ export { type ReadNerveYamlOptions, } from "./shared/context.js"; export { isDryRun } from "./role-types.js"; +export { withDryRun, type WithDryRunOptions } from "./with-dry-run.js"; export { nerveCommandEnv, spawnSafe, diff --git a/packages/workflow-utils/src/with-dry-run.ts b/packages/workflow-utils/src/with-dry-run.ts new file mode 100644 index 0000000..02e64ba --- /dev/null +++ b/packages/workflow-utils/src/with-dry-run.ts @@ -0,0 +1,39 @@ +import type { Role, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; + +import { isDryRun } from "./role-types.js"; + +export type WithDryRunOptions = { + /** Used in skip / failure messages (e.g. "committer", "publish"). */ + label: string; + /** Meta returned when dry-run skips execution. */ + dryRunMeta: M; + /** Meta returned when the inner role throws. */ + failMeta: M; +}; + +/** + * Wraps a role so dry-run short-circuits with a stable message/meta, and + * thrown errors become a structured failure result instead of propagating. + */ +export function withDryRun>( + role: Role, + opts: WithDryRunOptions, +): Role { + return async (start: StartStep, messages: WorkflowMessage[]) => { + if (isDryRun(start)) { + return { + content: `[dry-run] ${opts.label} skipped`, + meta: opts.dryRunMeta, + }; + } + 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.failMeta, + }; + } + }; +} -- 2.43.0 From c08e7f085d58879cd4e5d37ed5746bd4c98536c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 29 Apr 2026 13:23:40 +0000 Subject: [PATCH 2/3] refactor(workflow-utils): split withDryRun into decorator chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - withDryRun(opts) — only handles dry-run skip - onFail(opts) — only handles try/catch error wrapping - decorateRole(role, [...]) — composes decorators left-to-right - RoleDecorator type for custom decorators Addresses review feedback on #255 --- docs/rfc-004-package-architecture.md | 168 +++++++++++++++++ .../src/__tests__/with-dry-run.test.ts | 169 +++++++++++------- packages/workflow-utils/src/index.ts | 9 +- packages/workflow-utils/src/with-dry-run.ts | 105 ++++++++--- 4 files changed, 357 insertions(+), 94 deletions(-) create mode 100644 docs/rfc-004-package-architecture.md diff --git a/docs/rfc-004-package-architecture.md b/docs/rfc-004-package-architecture.md new file mode 100644 index 0000000..90eb8ff --- /dev/null +++ b/docs/rfc-004-package-architecture.md @@ -0,0 +1,168 @@ +# 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, withDryRun, 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>; + extract: LlmExtractorConfig; + cwd: string; +}; + +export function createDevelopSenseWorkflow(deps: CreateDevelopSenseDeps): WorkflowDefinition; +``` + +#### Role Package + +```ts +// @uncaged/nerve-role-committer +import type { AgentFn, Role } from "@uncaged/nerve-core"; +import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; + +export type CommitterMeta = { committed: boolean }; + +export function createCommitterRole(adapter: AgentFn, extract: LlmExtractorConfig): Role; +``` + +#### 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; +``` + +### 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` / `withDryRun` in `workflow-utils` — building blocks that role packages compose diff --git a/packages/workflow-utils/src/__tests__/with-dry-run.test.ts b/packages/workflow-utils/src/__tests__/with-dry-run.test.ts index 9e9eebb..fa7e1ff 100644 --- a/packages/workflow-utils/src/__tests__/with-dry-run.test.ts +++ b/packages/workflow-utils/src/__tests__/with-dry-run.test.ts @@ -1,81 +1,114 @@ -import type { Role, WorkflowMessage } from "@uncaged/nerve-core"; -import { START } from "@uncaged/nerve-core"; import { describe, expect, it } from "vitest"; -import { withDryRun } from "../with-dry-run.js"; +import type { + Role, + RoleResult, + StartStep, + WorkflowMessage, +} from "@uncaged/nerve-core"; -function makeStart(dryRun: boolean, threadId = "t1") { +import { decorateRole, onFail, withDryRun } from "../with-dry-run.js"; + +type TestMeta = { ok: boolean }; + +function fakeStart(dryRun: boolean): StartStep { return { - role: START, - content: "", - meta: { maxRounds: 10, dryRun, threadId }, - timestamp: Date.now(), + role: "test", + meta: { + threadId: "t1", + dryRun, + startedAt: new Date().toISOString(), + }, }; } +const successRole: Role = async () => ({ + content: "done", + meta: { ok: true }, +}); + +const failRole: Role = async () => { + throw new Error("boom"); +}; + +const failNonErrorRole: Role = async () => { + throw "string error"; +}; + +// --------------------------------------------------------------------------- +// withDryRun +// --------------------------------------------------------------------------- + describe("withDryRun", () => { - it("returns dry-run message and meta when start.meta.dryRun is true", async () => { - const inner: Role<{ committed: boolean }> = async () => ({ - content: "should not run", - meta: { committed: true }, - }); - const wrapped = withDryRun(inner, { - label: "committer", - dryRunMeta: { committed: true }, - failMeta: { committed: false }, - }); + const dec = withDryRun({ label: "test", meta: { ok: true } }); - const out = await wrapped(makeStart(true), []); - expect(out).toEqual({ - content: "[dry-run] committer skipped", - meta: { committed: 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 to inner role when not dry-run", async () => { - const inner: Role<{ ok: boolean }> = async (_start, messages) => ({ - content: `n=${messages.length}`, - meta: { ok: true }, - }); - const wrapped = withDryRun(inner, { - label: "publish", - dryRunMeta: { ok: false }, - failMeta: { ok: false }, - }); - - const msgs: WorkflowMessage[] = [{ role: "a", content: "x", meta: {}, timestamp: 1 }]; - const out = await wrapped(makeStart(false), msgs); - expect(out.content).toBe("n=1"); - expect(out.meta).toEqual({ ok: true }); - }); - - it("maps thrown Error to fail content and failMeta", async () => { - const inner: Role<{ committed: boolean }> = async () => { - throw new Error("disk full"); - }; - const wrapped = withDryRun(inner, { - label: "committer", - dryRunMeta: { committed: true }, - failMeta: { committed: false }, - }); - - const out = await wrapped(makeStart(false), []); - expect(out.content).toBe("committer failed: disk full"); - expect(out.meta).toEqual({ committed: false }); - }); - - it("maps non-Error throw to fail content and failMeta", async () => { - const inner: Role<{ committed: boolean }> = async () => { - throw 404; - }; - const wrapped = withDryRun(inner, { - label: "committer", - dryRunMeta: { committed: true }, - failMeta: { committed: false }, - }); - - const out = await wrapped(makeStart(false), []); - expect(out.content).toBe("committer failed: 404"); - expect(out.meta).toEqual({ committed: false }); + 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({ 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 }); }); }); diff --git a/packages/workflow-utils/src/index.ts b/packages/workflow-utils/src/index.ts index f9ff5af..bd90aca 100644 --- a/packages/workflow-utils/src/index.ts +++ b/packages/workflow-utils/src/index.ts @@ -20,7 +20,14 @@ export { type ReadNerveYamlOptions, } from "./shared/context.js"; export { isDryRun } from "./role-types.js"; -export { withDryRun, type WithDryRunOptions } from "./with-dry-run.js"; +export { + decorateRole, + withDryRun, + onFail, + type RoleDecorator, + type WithDryRunOptions, + type OnFailOptions, +} from "./with-dry-run.js"; export { nerveCommandEnv, spawnSafe, diff --git a/packages/workflow-utils/src/with-dry-run.ts b/packages/workflow-utils/src/with-dry-run.ts index 02e64ba..a5cec4e 100644 --- a/packages/workflow-utils/src/with-dry-run.ts +++ b/packages/workflow-utils/src/with-dry-run.ts @@ -2,38 +2,93 @@ 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> = ( + role: Role, +) => Role; + +// --------------------------------------------------------------------------- +// 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>( + role: Role, + decorators: RoleDecorator[], +): Role { + return decorators.reduce((r, dec) => dec(r), role); +} + +// --------------------------------------------------------------------------- +// withDryRun — skip execution when dry-run is active +// --------------------------------------------------------------------------- + export type WithDryRunOptions = { - /** Used in skip / failure messages (e.g. "committer", "publish"). */ + /** Used in skip message (e.g. "committer", "publish"). */ label: string; /** Meta returned when dry-run skips execution. */ - dryRunMeta: M; - /** Meta returned when the inner role throws. */ - failMeta: M; + meta: M; }; /** - * Wraps a role so dry-run short-circuits with a stable message/meta, and - * thrown errors become a structured failure result instead of propagating. + * Returns a decorator that short-circuits with a stable result when + * `start.meta.dryRun` is true. */ export function withDryRun>( - role: Role, opts: WithDryRunOptions, -): Role { - return async (start: StartStep, messages: WorkflowMessage[]) => { - if (isDryRun(start)) { - return { - content: `[dry-run] ${opts.label} skipped`, - meta: opts.dryRunMeta, - }; - } - 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.failMeta, - }; - } - }; +): RoleDecorator { + 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 = { + /** 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>( + opts: OnFailOptions, +): RoleDecorator { + 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, + }; + } + }; } -- 2.43.0 From 9c11cd53e611792b05bd50f4dd03fc19089da790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 29 Apr 2026 13:27:08 +0000 Subject: [PATCH 3/3] =?UTF-8?q?rename=20with-dry-run=20=E2=86=92=20role-de?= =?UTF-8?q?corators,=20remove=20RFC-004=20from=20this=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/rfc-004-package-architecture.md | 168 ------------------ ...ry-run.test.ts => role-decorators.test.ts} | 2 +- packages/workflow-utils/src/index.ts | 2 +- .../{with-dry-run.ts => role-decorators.ts} | 0 4 files changed, 2 insertions(+), 170 deletions(-) delete mode 100644 docs/rfc-004-package-architecture.md rename packages/workflow-utils/src/__tests__/{with-dry-run.test.ts => role-decorators.test.ts} (97%) rename packages/workflow-utils/src/{with-dry-run.ts => role-decorators.ts} (100%) diff --git a/docs/rfc-004-package-architecture.md b/docs/rfc-004-package-architecture.md deleted file mode 100644 index 90eb8ff..0000000 --- a/docs/rfc-004-package-architecture.md +++ /dev/null @@ -1,168 +0,0 @@ -# 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, withDryRun, 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>; - extract: LlmExtractorConfig; - cwd: string; -}; - -export function createDevelopSenseWorkflow(deps: CreateDevelopSenseDeps): WorkflowDefinition; -``` - -#### Role Package - -```ts -// @uncaged/nerve-role-committer -import type { AgentFn, Role } from "@uncaged/nerve-core"; -import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; - -export type CommitterMeta = { committed: boolean }; - -export function createCommitterRole(adapter: AgentFn, extract: LlmExtractorConfig): Role; -``` - -#### 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; -``` - -### 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` / `withDryRun` in `workflow-utils` — building blocks that role packages compose diff --git a/packages/workflow-utils/src/__tests__/with-dry-run.test.ts b/packages/workflow-utils/src/__tests__/role-decorators.test.ts similarity index 97% rename from packages/workflow-utils/src/__tests__/with-dry-run.test.ts rename to packages/workflow-utils/src/__tests__/role-decorators.test.ts index fa7e1ff..30c19f2 100644 --- a/packages/workflow-utils/src/__tests__/with-dry-run.test.ts +++ b/packages/workflow-utils/src/__tests__/role-decorators.test.ts @@ -7,7 +7,7 @@ import type { WorkflowMessage, } from "@uncaged/nerve-core"; -import { decorateRole, onFail, withDryRun } from "../with-dry-run.js"; +import { decorateRole, onFail, withDryRun } from "../role-decorators.js"; type TestMeta = { ok: boolean }; diff --git a/packages/workflow-utils/src/index.ts b/packages/workflow-utils/src/index.ts index bd90aca..56bc046 100644 --- a/packages/workflow-utils/src/index.ts +++ b/packages/workflow-utils/src/index.ts @@ -27,7 +27,7 @@ export { type RoleDecorator, type WithDryRunOptions, type OnFailOptions, -} from "./with-dry-run.js"; +} from "./role-decorators.js"; export { nerveCommandEnv, spawnSafe, diff --git a/packages/workflow-utils/src/with-dry-run.ts b/packages/workflow-utils/src/role-decorators.ts similarity index 100% rename from packages/workflow-utils/src/with-dry-run.ts rename to packages/workflow-utils/src/role-decorators.ts -- 2.43.0