Migrate workflows to WorkflowSpec-style roles (RFC-003)

Replace createCursorRole/createHermesRole with adapter + prompt + zod meta.

Add shared compileRoleSpec, cursor ask adapter, nerve.yaml extract defaults.

Refs #248

Made-with: Cursor
This commit is contained in:
小橘 🍊(NEKO Team) 2026-04-29 09:23:55 +00:00 committed by 小橘
parent 66ce30cdfb
commit 56ce22fb1b
30 changed files with 389 additions and 257 deletions

View File

@ -1,4 +1,9 @@
# nerve.yaml — Nerve workspace configuration # nerve.yaml — Nerve workspace configuration
extract:
provider: dashscope
model: qwen-plus
senses: senses:
linux-system-health: linux-system-health:
group: system group: system

View File

@ -7,6 +7,8 @@
"build": "pnpm -r build" "build": "pnpm -r build"
}, },
"dependencies": { "dependencies": {
"@uncaged/nerve-adapter-cursor": "link:../repos/nerve/packages/adapter-cursor",
"@uncaged/nerve-adapter-hermes": "link:../repos/nerve/packages/adapter-hermes",
"@uncaged/nerve-core": "latest", "@uncaged/nerve-core": "latest",
"@uncaged/nerve-daemon": "latest", "@uncaged/nerve-daemon": "latest",
"@uncaged/nerve-workflow-utils": "link:../repos/nerve/packages/workflow-utils", "@uncaged/nerve-workflow-utils": "link:../repos/nerve/packages/workflow-utils",
@ -21,6 +23,8 @@
"esbuild" "esbuild"
], ],
"overrides": { "overrides": {
"@uncaged/nerve-adapter-cursor": "link:../repos/nerve/packages/adapter-cursor",
"@uncaged/nerve-adapter-hermes": "link:../repos/nerve/packages/adapter-hermes",
"@uncaged/nerve-daemon": "link:../repos/nerve/packages/daemon", "@uncaged/nerve-daemon": "link:../repos/nerve/packages/daemon",
"@uncaged/nerve-core": "link:../repos/nerve/packages/core", "@uncaged/nerve-core": "link:../repos/nerve/packages/core",
"@uncaged/nerve-workflow-utils": "link:../repos/nerve/packages/workflow-utils" "@uncaged/nerve-workflow-utils": "link:../repos/nerve/packages/workflow-utils"

30
pnpm-lock.yaml generated
View File

@ -5,6 +5,8 @@ settings:
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides: overrides:
'@uncaged/nerve-adapter-cursor': link:../repos/nerve/packages/adapter-cursor
'@uncaged/nerve-adapter-hermes': link:../repos/nerve/packages/adapter-hermes
'@uncaged/nerve-daemon': link:../repos/nerve/packages/daemon '@uncaged/nerve-daemon': link:../repos/nerve/packages/daemon
'@uncaged/nerve-core': link:../repos/nerve/packages/core '@uncaged/nerve-core': link:../repos/nerve/packages/core
'@uncaged/nerve-workflow-utils': link:../repos/nerve/packages/workflow-utils '@uncaged/nerve-workflow-utils': link:../repos/nerve/packages/workflow-utils
@ -13,6 +15,12 @@ importers:
.: .:
dependencies: dependencies:
'@uncaged/nerve-adapter-cursor':
specifier: link:../repos/nerve/packages/adapter-cursor
version: link:../repos/nerve/packages/adapter-cursor
'@uncaged/nerve-adapter-hermes':
specifier: link:../repos/nerve/packages/adapter-hermes
version: link:../repos/nerve/packages/adapter-hermes
'@uncaged/nerve-core': '@uncaged/nerve-core':
specifier: link:../repos/nerve/packages/core specifier: link:../repos/nerve/packages/core
version: link:../repos/nerve/packages/core version: link:../repos/nerve/packages/core
@ -93,8 +101,14 @@ importers:
specifier: ^5.7.0 specifier: ^5.7.0
version: 5.9.3 version: 5.9.3
workflows/generate-sense: workflows/develop-sense:
dependencies: dependencies:
'@uncaged/nerve-adapter-cursor':
specifier: link:../../../repos/nerve/packages/adapter-cursor
version: link:../../../repos/nerve/packages/adapter-cursor
'@uncaged/nerve-adapter-hermes':
specifier: link:../../../repos/nerve/packages/adapter-hermes
version: link:../../../repos/nerve/packages/adapter-hermes
'@uncaged/nerve-core': '@uncaged/nerve-core':
specifier: link:../../../repos/nerve/packages/core specifier: link:../../../repos/nerve/packages/core
version: link:../../../repos/nerve/packages/core version: link:../../../repos/nerve/packages/core
@ -115,8 +129,14 @@ importers:
specifier: ^5.7.0 specifier: ^5.7.0
version: 5.9.3 version: 5.9.3
workflows/generate-workflow: workflows/develop-workflow:
dependencies: dependencies:
'@uncaged/nerve-adapter-cursor':
specifier: link:../../../repos/nerve/packages/adapter-cursor
version: link:../../../repos/nerve/packages/adapter-cursor
'@uncaged/nerve-adapter-hermes':
specifier: link:../../../repos/nerve/packages/adapter-hermes
version: link:../../../repos/nerve/packages/adapter-hermes
'@uncaged/nerve-core': '@uncaged/nerve-core':
specifier: link:../../../repos/nerve/packages/core specifier: link:../../../repos/nerve/packages/core
version: link:../../../repos/nerve/packages/core version: link:../../../repos/nerve/packages/core
@ -139,6 +159,12 @@ importers:
workflows/solve-issue: workflows/solve-issue:
dependencies: dependencies:
'@uncaged/nerve-adapter-cursor':
specifier: link:../../../repos/nerve/packages/adapter-cursor
version: link:../../../repos/nerve/packages/adapter-cursor
'@uncaged/nerve-adapter-hermes':
specifier: link:../../../repos/nerve/packages/adapter-hermes
version: link:../../../repos/nerve/packages/adapter-hermes
'@uncaged/nerve-core': '@uncaged/nerve-core':
specifier: link:../../../repos/nerve/packages/core specifier: link:../../../repos/nerve/packages/core
version: link:../../../repos/nerve/packages/core version: link:../../../repos/nerve/packages/core

View File

@ -0,0 +1,46 @@
import { cursorAgent } from "@uncaged/nerve-adapter-cursor";
import type { AgentFn, WorkflowContext } from "@uncaged/nerve-core";
const DEFAULT_MS = 300_000;
function throwCursorError(error: {
kind: string;
exitCode?: number;
stdout?: string;
stderr?: string;
message?: string;
}): never {
if (error.kind === "non_zero_exit") {
throw new Error(
`cursor-agent: exitCode=${error.exitCode} stdout=${error.stdout} stderr=${error.stderr}`,
);
}
if (error.kind === "timeout") {
throw new Error("cursor-agent: timeout");
}
if (error.kind === "aborted") {
throw new Error("cursor-agent: aborted");
}
throw new Error(`cursor-agent: ${error.message ?? error.kind}`);
}
/** Cursor CLI in `--mode=ask` (planner-style roles). */
export function createAskCursorAdapter(config: { model: string; timeout: number | null }): AgentFn {
const timeoutMs = config.timeout ?? DEFAULT_MS;
return async (prompt: string, context: WorkflowContext): Promise<string> => {
const run = await cursorAgent({
prompt,
mode: "ask",
model: config.model,
cwd: context.workdir,
env: null,
timeoutMs,
dryRun: context.start.meta.dryRun,
abortSignal: context.signal,
});
if (!run.ok) {
throwCursorError(run.error);
}
return run.value;
};
}

View File

@ -0,0 +1,52 @@
import type {
Role,
RoleSpec,
StartStep,
WorkflowContext,
WorkflowMessage,
} from "@uncaged/nerve-core";
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { extractMetaOrThrow, type ZodMetaSchema } from "@uncaged/nerve-workflow-utils";
import type { z } from "zod";
export function zodMeta<T>(zod: z.ZodType<T>): ZodMetaSchema<T> {
return { witness: null, zod };
}
export type CompileRoleSpecDeps = {
provider: LlmProvider;
createContext: (start: StartStep, messages: WorkflowMessage[]) => WorkflowContext;
};
/**
* RFC-003 RoleSpec runtime Role (extract uses global nerve.yaml provider via `provider`;
* dryRun follows each invocation's start frame).
*/
export function compileRoleSpec<M extends Record<string, unknown>>(
spec: RoleSpec<M>,
deps: CompileRoleSpecDeps,
): Role<M> {
return async (start, messages) => {
const ctx = deps.createContext(start, messages);
const promptText =
typeof spec.prompt === "string" ? spec.prompt : await spec.prompt(start, messages);
const raw = await spec.adapter(promptText, ctx);
const zod = (spec.meta as ZodMetaSchema<M>).zod;
const meta = await extractMetaOrThrow(raw, zod, {
provider: deps.provider,
dryRun: start.meta.dryRun,
});
return { content: raw, meta };
};
}
export function defaultAgentCreateContext(
workdir: string,
): (start: StartStep, messages: WorkflowMessage[]) => WorkflowContext {
return (start, messages) => ({
start,
messages,
workdir,
signal: new AbortController().signal,
});
}

View File

@ -1,9 +1,19 @@
import type { WorkflowDefinition } from "@uncaged/nerve-core"; import type { StartStep, WorkflowDefinition } from "@uncaged/nerve-core";
import { createCursorAdapter } from "@uncaged/nerve-adapter-cursor";
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { buildPlannerRole } from "./roles/planner/index.js";
import { buildCoderRole } from "./roles/coder/index.js"; import { compileRoleSpec, defaultAgentCreateContext, zodMeta } from "../_shared/rfc003-compile.js";
import { buildReviewerRole } from "./roles/reviewer/index.js"; import { createAskCursorAdapter } from "../_shared/cursor-ask-adapter.js";
import { buildTesterRole } from "./roles/tester/index.js";
import { coderPrompt } from "./roles/coder/prompt.js";
import { coderMetaSchema } from "./roles/coder/index.js";
import { plannerPrompt } from "./roles/planner/prompt.js";
import { plannerMetaSchema } from "./roles/planner/index.js";
import { reviewerPrompt } from "./roles/reviewer/prompt.js";
import { reviewerMetaSchema } from "./roles/reviewer/index.js";
import { testerPrompt } from "./roles/tester/prompt.js";
import { testerMetaSchema } from "./roles/tester/index.js";
import { buildCommitterRole } from "./roles/committer/index.js"; import { buildCommitterRole } from "./roles/committer/index.js";
import { moderator } from "./moderator.js"; import { moderator } from "./moderator.js";
import type { SenseMeta } from "./moderator.js"; import type { SenseMeta } from "./moderator.js";
@ -13,17 +23,51 @@ export type BuildSenseGeneratorDeps = {
cwd: string; cwd: string;
}; };
const CURSOR_TIMEOUT_MS = 300_000;
export function buildSenseGenerator({ export function buildSenseGenerator({
provider, provider,
cwd, cwd,
}: BuildSenseGeneratorDeps): WorkflowDefinition<SenseMeta> { }: BuildSenseGeneratorDeps): WorkflowDefinition<SenseMeta> {
const createContext = defaultAgentCreateContext(cwd);
const deps = { provider, createContext };
const agentRoles = {
planner: {
adapter: createAskCursorAdapter({ model: "auto", timeout: CURSOR_TIMEOUT_MS }),
prompt: async (start: StartStep) => plannerPrompt({ threadId: start.meta.threadId }),
meta: zodMeta(plannerMetaSchema),
},
coder: {
adapter: createCursorAdapter({
type: "cursor",
model: "auto",
timeout: CURSOR_TIMEOUT_MS,
}),
prompt: async (start: StartStep) => coderPrompt({ threadId: start.meta.threadId }),
meta: zodMeta(coderMetaSchema),
},
reviewer: {
adapter: hermesAdapter,
prompt: async (start: StartStep) =>
reviewerPrompt({ threadId: start.meta.threadId, nerveRoot: cwd }),
meta: zodMeta(reviewerMetaSchema),
},
tester: {
adapter: hermesAdapter,
prompt: async (start: StartStep) =>
testerPrompt({ threadId: start.meta.threadId, nerveRoot: cwd }),
meta: zodMeta(testerMetaSchema),
},
};
return { return {
name: "develop-sense", name: "develop-sense",
roles: { roles: {
planner: buildPlannerRole({ provider, cwd }), planner: compileRoleSpec(agentRoles.planner, deps),
coder: buildCoderRole({ provider, cwd }), coder: compileRoleSpec(agentRoles.coder, deps),
reviewer: buildReviewerRole({ provider, nerveRoot: cwd }), reviewer: compileRoleSpec(agentRoles.reviewer, deps),
tester: buildTesterRole({ provider, nerveRoot: cwd }), tester: compileRoleSpec(agentRoles.tester, deps),
committer: buildCommitterRole({ nerveRoot: cwd }), committer: buildCommitterRole({ nerveRoot: cwd }),
}, },
moderator, moderator,

View File

@ -42,7 +42,7 @@ export const moderator: Moderator<SenseMeta> = (context) => {
if (last.role === "planner") return "coder"; if (last.role === "planner") return "coder";
if (last.role === "coder") { if (last.role === "coder") {
if (last.meta.done) return "reviewer"; if (last.meta.filesCreated) return "reviewer";
return canRetryCoder(context.steps) ? "coder" : END; return canRetryCoder(context.steps) ? "coder" : END;
} }

View File

@ -7,6 +7,8 @@
"build": "esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external" "build": "esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external"
}, },
"dependencies": { "dependencies": {
"@uncaged/nerve-adapter-cursor": "latest",
"@uncaged/nerve-adapter-hermes": "latest",
"@uncaged/nerve-core": "latest", "@uncaged/nerve-core": "latest",
"@uncaged/nerve-workflow-utils": "latest", "@uncaged/nerve-workflow-utils": "latest",
"zod": "^4.3.6" "zod": "^4.3.6"

View File

@ -1,23 +1,6 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createCursorRole } from "@uncaged/nerve-workflow-utils";
import { coderPrompt } from "./prompt.js";
import { z } from "zod"; import { z } from "zod";
export const coderMetaSchema = z.object({ export const coderMetaSchema = z.object({
filesCreated: z.boolean().describe("true if the sense files were created"), filesCreated: z.boolean().describe("true if the sense files were created"),
}); });
export type CoderMeta = z.infer<typeof coderMetaSchema>; export type CoderMeta = z.infer<typeof coderMetaSchema>;
export type BuildCoderDeps = {
provider: LlmProvider;
cwd: string;
};
export function buildCoderRole({ provider, cwd }: BuildCoderDeps) {
return createCursorRole<CoderMeta>({
cwd,
mode: "default",
prompt: async (threadId) => coderPrompt({ threadId }),
extract: { provider, schema: coderMetaSchema },
});
}

View File

@ -37,7 +37,13 @@ export function buildCommitterRole({ nerveRoot }: BuildCommitterDeps): Role<Comm
let success = true; let success = true;
const run = async (cmd: string, args: string[]): Promise<boolean> => { const run = async (cmd: string, args: string[]): Promise<boolean> => {
const r = await spawnSafe(cmd, args, { cwd: nerveRoot, env: null, timeoutMs: 60_000, dryRun: false }); const r = await spawnSafe(cmd, args, {
cwd: nerveRoot,
env: null,
timeoutMs: 60_000,
dryRun: false,
abortSignal: null,
});
if (r.ok) { if (r.ok) {
lines.push(`$ ${cmd} ${args.join(" ")}`); lines.push(`$ ${cmd} ${args.join(" ")}`);
if (r.value.stdout) lines.push(r.value.stdout); if (r.value.stdout) lines.push(r.value.stdout);
@ -55,8 +61,10 @@ export function buildCommitterRole({ nerveRoot }: BuildCommitterDeps): Role<Comm
lines.push("timeout"); lines.push("timeout");
if (e.stdout) lines.push(e.stdout); if (e.stdout) lines.push(e.stdout);
if (e.stderr) lines.push(e.stderr); if (e.stderr) lines.push(e.stderr);
} else { } else if (e.kind === "spawn_failed") {
lines.push(e.message); lines.push(e.message);
} else {
lines.push(e.kind === "aborted" ? "aborted" : "error");
} }
lines.push(""); lines.push("");
return false; return false;

View File

@ -1,23 +1,6 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createCursorRole } from "@uncaged/nerve-workflow-utils";
import { plannerPrompt } from "./prompt.js";
import { z } from "zod"; import { z } from "zod";
export const plannerMetaSchema = z.object({ export const plannerMetaSchema = z.object({
senseName: z.string().describe("kebab-case sense name from the plan"), senseName: z.string().describe("kebab-case sense name from the plan"),
}); });
export type PlannerMeta = z.infer<typeof plannerMetaSchema>; export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
export type BuildPlannerDeps = {
provider: LlmProvider;
cwd: string;
};
export function buildPlannerRole({ provider, cwd }: BuildPlannerDeps) {
return createCursorRole<PlannerMeta>({
cwd,
mode: "ask",
prompt: async (threadId) => plannerPrompt({ threadId }),
extract: { provider, schema: plannerMetaSchema },
});
}

View File

@ -1,21 +1,6 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createHermesRole } from "@uncaged/nerve-workflow-utils";
import { reviewerPrompt } from "./prompt.js";
import { z } from "zod"; import { z } from "zod";
export const reviewerMetaSchema = z.object({ export const reviewerMetaSchema = z.object({
approved: z.boolean().describe("true if the diff is clean and ready for tester validation"), approved: z.boolean().describe("true if the diff is clean and ready for tester validation"),
}); });
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>; export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
export type BuildReviewerDeps = {
provider: LlmProvider;
nerveRoot: string;
};
export function buildReviewerRole({ provider, nerveRoot }: BuildReviewerDeps) {
return createHermesRole<ReviewerMeta>({
prompt: async (threadId) => reviewerPrompt({ threadId, nerveRoot }),
extract: { provider, schema: reviewerMetaSchema },
});
}

View File

@ -1,21 +1,6 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createHermesRole } from "@uncaged/nerve-workflow-utils";
import { testerPrompt } from "./prompt.js";
import { z } from "zod"; import { z } from "zod";
export const testerMetaSchema = z.object({ export const testerMetaSchema = z.object({
passed: z.boolean().describe("true if all e2e checks passed"), passed: z.boolean().describe("true if all e2e checks passed"),
}); });
export type TesterMeta = z.infer<typeof testerMetaSchema>; export type TesterMeta = z.infer<typeof testerMetaSchema>;
export type BuildTesterDeps = {
provider: LlmProvider;
nerveRoot: string;
};
export function buildTesterRole({ provider, nerveRoot }: BuildTesterDeps) {
return createHermesRole<TesterMeta>({
prompt: async (threadId) => testerPrompt({ threadId, nerveRoot }),
extract: { provider, schema: testerMetaSchema },
});
}

View File

@ -1,10 +1,19 @@
import { join } from "node:path"; import type { StartStep, WorkflowDefinition } from "@uncaged/nerve-core";
import type { WorkflowDefinition } from "@uncaged/nerve-core"; import { createCursorAdapter } from "@uncaged/nerve-adapter-cursor";
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { buildPlannerRole } from "./roles/planner/index.js";
import { buildCoderRole } from "./roles/coder/index.js"; import { compileRoleSpec, defaultAgentCreateContext, zodMeta } from "../_shared/rfc003-compile.js";
import { buildReviewerRole } from "./roles/reviewer/index.js"; import { createAskCursorAdapter } from "../_shared/cursor-ask-adapter.js";
import { buildTesterRole } from "./roles/tester/index.js";
import { coderPrompt } from "./roles/coder/prompt.js";
import { coderMetaSchema } from "./roles/coder/index.js";
import { plannerPrompt } from "./roles/planner/prompt.js";
import { plannerMetaSchema } from "./roles/planner/index.js";
import { reviewerPrompt } from "./roles/reviewer/prompt.js";
import { reviewerMetaSchema } from "./roles/reviewer/index.js";
import { testerPrompt } from "./roles/tester/prompt.js";
import { testerMetaSchema } from "./roles/tester/index.js";
import { buildCommitterRole } from "./roles/committer/index.js"; import { buildCommitterRole } from "./roles/committer/index.js";
import { moderator } from "./moderator.js"; import { moderator } from "./moderator.js";
import type { WorkflowMeta } from "./moderator.js"; import type { WorkflowMeta } from "./moderator.js";
@ -14,17 +23,51 @@ export type BuildWorkflowGeneratorDeps = {
nerveRoot: string; nerveRoot: string;
}; };
const CURSOR_TIMEOUT_MS = 300_000;
export function buildWorkflowGenerator({ export function buildWorkflowGenerator({
provider, provider,
nerveRoot, nerveRoot,
}: BuildWorkflowGeneratorDeps): WorkflowDefinition<WorkflowMeta> { }: BuildWorkflowGeneratorDeps): WorkflowDefinition<WorkflowMeta> {
const createContext = defaultAgentCreateContext(nerveRoot);
const deps = { provider, createContext };
const agentRoles = {
planner: {
adapter: createAskCursorAdapter({ model: "auto", timeout: CURSOR_TIMEOUT_MS }),
prompt: async (start: StartStep) => plannerPrompt({ threadId: start.meta.threadId }),
meta: zodMeta(plannerMetaSchema),
},
coder: {
adapter: createCursorAdapter({
type: "cursor",
model: "auto",
timeout: CURSOR_TIMEOUT_MS,
}),
prompt: async (start: StartStep) => coderPrompt({ threadId: start.meta.threadId }),
meta: zodMeta(coderMetaSchema),
},
reviewer: {
adapter: hermesAdapter,
prompt: async (start: StartStep) =>
reviewerPrompt({ threadId: start.meta.threadId, nerveRoot }),
meta: zodMeta(reviewerMetaSchema),
},
tester: {
adapter: hermesAdapter,
prompt: async (start: StartStep) =>
testerPrompt({ threadId: start.meta.threadId, nerveRoot }),
meta: zodMeta(testerMetaSchema),
},
};
return { return {
name: "develop-workflow", name: "develop-workflow",
roles: { roles: {
planner: buildPlannerRole({ provider, cwd: nerveRoot }), planner: compileRoleSpec(agentRoles.planner, deps),
coder: buildCoderRole({ provider, cwd: nerveRoot }), coder: compileRoleSpec(agentRoles.coder, deps),
reviewer: buildReviewerRole({ provider, nerveRoot }), reviewer: compileRoleSpec(agentRoles.reviewer, deps),
tester: buildTesterRole({ provider, nerveRoot }), tester: compileRoleSpec(agentRoles.tester, deps),
committer: buildCommitterRole({ nerveRoot }), committer: buildCommitterRole({ nerveRoot }),
}, },
moderator, moderator,

View File

@ -7,6 +7,8 @@
"build": "esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external" "build": "esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external"
}, },
"dependencies": { "dependencies": {
"@uncaged/nerve-adapter-cursor": "latest",
"@uncaged/nerve-adapter-hermes": "latest",
"@uncaged/nerve-core": "latest", "@uncaged/nerve-core": "latest",
"@uncaged/nerve-workflow-utils": "latest", "@uncaged/nerve-workflow-utils": "latest",
"zod": "^4.3.6" "zod": "^4.3.6"

View File

@ -1,23 +1,6 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createCursorRole } from "@uncaged/nerve-workflow-utils";
import { coderPrompt } from "./prompt.js";
import { z } from "zod"; import { z } from "zod";
export const coderMetaSchema = z.object({ export const coderMetaSchema = z.object({
done: z.boolean().describe("true if the workflow files were created and build passes"), done: z.boolean().describe("true if the workflow files were created and build passes"),
}); });
export type CoderMeta = z.infer<typeof coderMetaSchema>; export type CoderMeta = z.infer<typeof coderMetaSchema>;
export type BuildCoderDeps = {
provider: LlmProvider;
cwd: string;
};
export function buildCoderRole({ provider, cwd }: BuildCoderDeps) {
return createCursorRole<CoderMeta>({
cwd,
mode: "default",
prompt: async (threadId) => coderPrompt({ threadId }),
extract: { provider, schema: coderMetaSchema },
});
}

View File

@ -37,7 +37,13 @@ export function buildCommitterRole({ nerveRoot }: BuildCommitterDeps): Role<Comm
let success = true; let success = true;
const run = async (cmd: string, args: string[]): Promise<boolean> => { const run = async (cmd: string, args: string[]): Promise<boolean> => {
const r = await spawnSafe(cmd, args, { cwd: nerveRoot, env: null, timeoutMs: 60_000, dryRun: false }); const r = await spawnSafe(cmd, args, {
cwd: nerveRoot,
env: null,
timeoutMs: 60_000,
dryRun: false,
abortSignal: null,
});
if (r.ok) { if (r.ok) {
lines.push(`$ ${cmd} ${args.join(" ")}`); lines.push(`$ ${cmd} ${args.join(" ")}`);
if (r.value.stdout) lines.push(r.value.stdout); if (r.value.stdout) lines.push(r.value.stdout);
@ -55,8 +61,10 @@ export function buildCommitterRole({ nerveRoot }: BuildCommitterDeps): Role<Comm
lines.push("timeout"); lines.push("timeout");
if (e.stdout) lines.push(e.stdout); if (e.stdout) lines.push(e.stdout);
if (e.stderr) lines.push(e.stderr); if (e.stderr) lines.push(e.stderr);
} else { } else if (e.kind === "spawn_failed") {
lines.push(e.message); lines.push(e.message);
} else {
lines.push(e.kind === "aborted" ? "aborted" : "error");
} }
lines.push(""); lines.push("");
return false; return false;

View File

@ -1,23 +1,6 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createCursorRole } from "@uncaged/nerve-workflow-utils";
import { plannerPrompt } from "./prompt.js";
import { z } from "zod"; import { z } from "zod";
export const plannerMetaSchema = z.object({ export const plannerMetaSchema = z.object({
ready: z.boolean().describe("true if requirements are clear and a workflow can be implemented"), ready: z.boolean().describe("true if requirements are clear and a workflow can be implemented"),
}); });
export type PlannerMeta = z.infer<typeof plannerMetaSchema>; export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
export type BuildPlannerDeps = {
provider: LlmProvider;
cwd: string;
};
export function buildPlannerRole({ provider, cwd }: BuildPlannerDeps) {
return createCursorRole<PlannerMeta>({
cwd,
mode: "ask",
prompt: async (threadId) => plannerPrompt({ threadId }),
extract: { provider, schema: plannerMetaSchema },
});
}

View File

@ -1,21 +1,6 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createHermesRole } from "@uncaged/nerve-workflow-utils";
import { reviewerPrompt } from "./prompt.js";
import { z } from "zod"; import { z } from "zod";
export const reviewerMetaSchema = z.object({ export const reviewerMetaSchema = z.object({
approved: z.boolean().describe("true if the diff is clean and ready for tester validation"), approved: z.boolean().describe("true if the diff is clean and ready for tester validation"),
}); });
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>; export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
export type BuildReviewerDeps = {
provider: LlmProvider;
nerveRoot: string;
};
export function buildReviewerRole({ provider, nerveRoot }: BuildReviewerDeps) {
return createHermesRole<ReviewerMeta>({
prompt: async (threadId) => reviewerPrompt({ threadId, nerveRoot }),
extract: { provider, schema: reviewerMetaSchema },
});
}

View File

@ -1,21 +1,6 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createHermesRole } from "@uncaged/nerve-workflow-utils";
import { testerPrompt } from "./prompt.js";
import { z } from "zod"; import { z } from "zod";
export const testerMetaSchema = z.object({ export const testerMetaSchema = z.object({
passed: z.boolean().describe("true if all validation checks passed"), passed: z.boolean().describe("true if all validation checks passed"),
}); });
export type TesterMeta = z.infer<typeof testerMetaSchema>; export type TesterMeta = z.infer<typeof testerMetaSchema>;
export type BuildTesterDeps = {
provider: LlmProvider;
nerveRoot: string;
};
export function buildTesterRole({ provider, nerveRoot }: BuildTesterDeps) {
return createHermesRole<TesterMeta>({
prompt: async (threadId) => testerPrompt({ threadId, nerveRoot }),
extract: { provider, schema: testerMetaSchema },
});
}

View File

@ -1,14 +1,22 @@
import type { WorkflowDefinition } from "@uncaged/nerve-core"; import type { StartStep, WorkflowDefinition } from "@uncaged/nerve-core";
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { compileRoleSpec, defaultAgentCreateContext, zodMeta } from "../_shared/rfc003-compile.js";
import { moderator } from "./moderator.js"; import { moderator } from "./moderator.js";
import type { WorkflowMeta } from "./moderator.js"; import type { WorkflowMeta } from "./moderator.js";
import { buildImplementRole } from "./roles/implement/index.js"; import { buildImplementRole } from "./roles/implement/index.js";
import { buildPlanRole } from "./roles/plan/index.js"; import { buildPlanRole } from "./roles/plan/index.js";
import { buildPrepareRole } from "./roles/prepare/index.js"; import { prepareMetaSchema } from "./roles/prepare/index.js";
import { preparePrompt } from "./roles/prepare/prompt.js";
import { buildPublishRole } from "./roles/publish/index.js"; import { buildPublishRole } from "./roles/publish/index.js";
import { buildReadIssueRole } from "./roles/read-issue/index.js"; import { readIssueMetaSchema } from "./roles/read-issue/index.js";
import { buildReviewRole } from "./roles/review/index.js"; import { readIssuePrompt } from "./roles/read-issue/prompt.js";
import { buildTestRole } from "./roles/test/index.js"; import { reviewMetaSchema } from "./roles/review/index.js";
import { reviewPrompt } from "./roles/review/prompt.js";
import { testMetaSchema } from "./roles/test/index.js";
import { testPrompt } from "./roles/test/prompt.js";
export type BuildSolveIssueDeps = { export type BuildSolveIssueDeps = {
nerveRoot: string; nerveRoot: string;
@ -16,15 +24,42 @@ export type BuildSolveIssueDeps = {
}; };
export function buildSolveIssue({ nerveRoot, provider }: BuildSolveIssueDeps): WorkflowDefinition<WorkflowMeta> { export function buildSolveIssue({ nerveRoot, provider }: BuildSolveIssueDeps): WorkflowDefinition<WorkflowMeta> {
const createContext = defaultAgentCreateContext(nerveRoot);
const deps = { provider, createContext };
const agentRoles = {
"read-issue": {
adapter: hermesAdapter,
prompt: async (start: StartStep) => readIssuePrompt({ threadId: start.meta.threadId }),
meta: zodMeta(readIssueMetaSchema),
},
prepare: {
adapter: hermesAdapter,
prompt: async (start: StartStep) => preparePrompt({ threadId: start.meta.threadId }),
meta: zodMeta(prepareMetaSchema),
},
review: {
adapter: hermesAdapter,
prompt: async (start: StartStep) =>
reviewPrompt({ threadId: start.meta.threadId, nerveRoot }),
meta: zodMeta(reviewMetaSchema),
},
test: {
adapter: hermesAdapter,
prompt: async (start: StartStep) => testPrompt({ threadId: start.meta.threadId }),
meta: zodMeta(testMetaSchema),
},
};
return { return {
name: "solve-issue", name: "solve-issue",
roles: { roles: {
"read-issue": buildReadIssueRole({ provider }), "read-issue": compileRoleSpec(agentRoles["read-issue"], deps),
prepare: buildPrepareRole({ provider }), prepare: compileRoleSpec(agentRoles.prepare, deps),
plan: buildPlanRole({ provider, nerveRoot }), plan: buildPlanRole({ provider, nerveRoot }),
implement: buildImplementRole({ provider, nerveRoot }), implement: buildImplementRole({ provider, nerveRoot }),
review: buildReviewRole({ provider, nerveRoot }), review: compileRoleSpec(agentRoles.review, deps),
test: buildTestRole({ provider }), test: compileRoleSpec(agentRoles.test, deps),
publish: buildPublishRole({ provider, nerveRoot }), publish: buildPublishRole({ provider, nerveRoot }),
}, },
moderator, moderator,

View File

@ -2,7 +2,12 @@ import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { spawnSafe } from "@uncaged/nerve-workflow-utils"; import { spawnSafe } from "@uncaged/nerve-workflow-utils";
export async function cfgGet(nerveRoot: string, key: string): Promise<string | null> { export async function cfgGet(nerveRoot: string, key: string): Promise<string | null> {
const result = await spawnSafe("cfg", ["get", key], { cwd: nerveRoot, env: null, timeoutMs: 10_000 }); const result = await spawnSafe("cfg", ["get", key], {
cwd: nerveRoot,
env: null,
timeoutMs: 10_000,
abortSignal: null,
});
if (!result.ok) { if (!result.ok) {
return null; return null;
} }

View File

@ -7,6 +7,8 @@
"build": "esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external" "build": "esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external"
}, },
"dependencies": { "dependencies": {
"@uncaged/nerve-adapter-cursor": "latest",
"@uncaged/nerve-adapter-hermes": "latest",
"@uncaged/nerve-core": "latest", "@uncaged/nerve-core": "latest",
"@uncaged/nerve-workflow-utils": "latest", "@uncaged/nerve-workflow-utils": "latest",
"zod": "^4.3.6" "zod": "^4.3.6"

View File

@ -1,7 +1,10 @@
import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; import type { Role, RoleResult, RoleSpec, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
import { createCursorAdapter } from "@uncaged/nerve-adapter-cursor";
import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createCursorRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
import { compileRoleSpec, zodMeta } from "../../../_shared/rfc003-compile.js";
import { resolveRepoCwd } from "../../lib/repo-context.js"; import { resolveRepoCwd } from "../../lib/repo-context.js";
import { buildImplementPrompt } from "./prompt.js"; import { buildImplementPrompt } from "./prompt.js";
@ -15,7 +18,23 @@ export type BuildImplementDeps = {
nerveRoot: string; nerveRoot: string;
}; };
const CURSOR_TIMEOUT_MS = 300_000;
export function buildImplementRole({ provider, nerveRoot }: BuildImplementDeps): Role<ImplementMeta> { export function buildImplementRole({ provider, nerveRoot }: BuildImplementDeps): Role<ImplementMeta> {
const innerSpec = {
adapter: createCursorAdapter({
type: "cursor",
model: "auto",
timeout: CURSOR_TIMEOUT_MS,
}),
prompt: async (start: StartStep) =>
buildImplementPrompt({
threadId: start.meta.threadId,
nerveRoot,
}),
meta: zodMeta(implementMetaSchema),
} satisfies RoleSpec<ImplementMeta>;
return async (start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<ImplementMeta>> => { return async (start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<ImplementMeta>> => {
const cwd = resolveRepoCwd(messages); const cwd = resolveRepoCwd(messages);
if (cwd === null) { if (cwd === null) {
@ -25,22 +44,18 @@ export function buildImplementRole({ provider, nerveRoot }: BuildImplementDeps):
}; };
} }
const runRole = createCursorRole<ImplementMeta>({ const run = compileRoleSpec(innerSpec, {
cwd, provider,
mode: "default", createContext: (s, m) => ({
model: "auto", start: s,
env: {}, messages: m,
timeoutMs: 300_000, workdir: cwd,
prompt: async () => signal: new AbortController().signal,
buildImplementPrompt({
threadId: (start.meta as { threadId?: string }).threadId ?? "unknown",
nerveRoot,
}), }),
extract: { provider, schema: implementMetaSchema },
}); });
try { try {
return await runRole(start, messages); return await run(start, messages);
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
return { return {

View File

@ -1,7 +1,9 @@
import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; import type { Role, RoleResult, RoleSpec, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
import { createAskCursorAdapter } from "../../../_shared/cursor-ask-adapter.js";
import { compileRoleSpec, zodMeta } from "../../../_shared/rfc003-compile.js";
import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createCursorRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
import { resolveRepoCwd } from "../../lib/repo-context.js"; import { resolveRepoCwd } from "../../lib/repo-context.js";
import { buildPlanPrompt } from "./prompt.js"; import { buildPlanPrompt } from "./prompt.js";
@ -15,7 +17,19 @@ export type BuildPlanDeps = {
nerveRoot: string; nerveRoot: string;
}; };
const CURSOR_TIMEOUT_MS = 300_000;
export function buildPlanRole({ provider, nerveRoot }: BuildPlanDeps): Role<PlanMeta> { export function buildPlanRole({ provider, nerveRoot }: BuildPlanDeps): Role<PlanMeta> {
const innerSpec = {
adapter: createAskCursorAdapter({ model: "auto", timeout: CURSOR_TIMEOUT_MS }),
prompt: async (start: StartStep) =>
buildPlanPrompt({
threadId: start.meta.threadId,
nerveRoot,
}),
meta: zodMeta(planMetaSchema),
} satisfies RoleSpec<PlanMeta>;
return async (start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<PlanMeta>> => { return async (start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<PlanMeta>> => {
const cwd = resolveRepoCwd(messages); const cwd = resolveRepoCwd(messages);
if (cwd === null) { if (cwd === null) {
@ -25,22 +39,18 @@ export function buildPlanRole({ provider, nerveRoot }: BuildPlanDeps): Role<Plan
}; };
} }
const runRole = createCursorRole<PlanMeta>({ const run = compileRoleSpec(innerSpec, {
cwd, provider,
mode: "ask", createContext: (s, m) => ({
model: "auto", start: s,
env: {}, messages: m,
timeoutMs: 300_000, workdir: cwd,
prompt: async () => signal: new AbortController().signal,
buildPlanPrompt({
threadId: (start.meta as { threadId?: string }).threadId ?? "unknown",
nerveRoot,
}), }),
extract: { provider, schema: planMetaSchema },
}); });
try { try {
return await runRole(start, messages); return await run(start, messages);
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
return { return {

View File

@ -1,20 +1,6 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createHermesRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
import { preparePrompt } from "./prompt.js";
export const prepareMetaSchema = z.object({ export const prepareMetaSchema = z.object({
ready: z.boolean().describe("true if repo is ready and baseline build ok"), ready: z.boolean().describe("true if repo is ready and baseline build ok"),
}); });
export type PrepareMeta = z.infer<typeof prepareMetaSchema>; export type PrepareMeta = z.infer<typeof prepareMetaSchema>;
export type BuildPrepareDeps = {
provider: LlmProvider;
};
export function buildPrepareRole({ provider }: BuildPrepareDeps) {
return createHermesRole<PrepareMeta>({
prompt: async (threadId) => preparePrompt({ threadId }),
extract: { provider, schema: prepareMetaSchema },
});
}

View File

@ -1,9 +1,13 @@
import { mkdirSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; import type { Role, RoleResult, RoleSpec, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createHermesRole, isDryRun } from "@uncaged/nerve-workflow-utils"; import { isDryRun } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
import { compileRoleSpec, defaultAgentCreateContext, zodMeta } from "../../../_shared/rfc003-compile.js";
import { buildPublishPrompt } from "./prompt.js"; import { buildPublishPrompt } from "./prompt.js";
export const publishMetaSchema = z.object({ export const publishMetaSchema = z.object({
@ -21,9 +25,15 @@ function logPath(nerveRoot: string): string {
} }
export function buildPublishRole({ provider, nerveRoot }: BuildPublishDeps): Role<PublishMeta> { export function buildPublishRole({ provider, nerveRoot }: BuildPublishDeps): Role<PublishMeta> {
const hermes = createHermesRole<PublishMeta>({ const innerSpec = {
prompt: async (threadId) => buildPublishPrompt({ threadId, nerveRoot }), adapter: hermesAdapter,
extract: { provider, schema: publishMetaSchema }, prompt: async (start: StartStep) => buildPublishPrompt({ threadId: start.meta.threadId, nerveRoot }),
meta: zodMeta(publishMetaSchema),
} satisfies RoleSpec<PublishMeta>;
const runHermes = compileRoleSpec(innerSpec, {
provider,
createContext: defaultAgentCreateContext(nerveRoot),
}); });
return async (start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<PublishMeta>> => { return async (start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<PublishMeta>> => {
@ -40,7 +50,7 @@ export function buildPublishRole({ provider, nerveRoot }: BuildPublishDeps): Rol
} }
try { try {
return await hermes(start, messages); return await runHermes(start, messages);
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
const body = `publish failed: ${msg}\n`; const body = `publish failed: ${msg}\n`;

View File

@ -1,20 +1,6 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createHermesRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
import { readIssuePrompt } from "./prompt.js";
export const readIssueMetaSchema = z.object({ export const readIssueMetaSchema = z.object({
ready: z.boolean().describe("true if issue content was fetched and markers are present"), ready: z.boolean().describe("true if issue content was fetched and markers are present"),
}); });
export type ReadIssueMeta = z.infer<typeof readIssueMetaSchema>; export type ReadIssueMeta = z.infer<typeof readIssueMetaSchema>;
export type BuildReadIssueDeps = {
provider: LlmProvider;
};
export function buildReadIssueRole({ provider }: BuildReadIssueDeps) {
return createHermesRole<ReadIssueMeta>({
prompt: async (threadId) => readIssuePrompt({ threadId }),
extract: { provider, schema: readIssueMetaSchema },
});
}

View File

@ -1,21 +1,6 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createHermesRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
import { reviewPrompt } from "./prompt.js";
export const reviewMetaSchema = z.object({ export const reviewMetaSchema = z.object({
approved: z.boolean().describe("true if diff is clean and ready for tests"), approved: z.boolean().describe("true if diff is clean and ready for tests"),
}); });
export type ReviewMeta = z.infer<typeof reviewMetaSchema>; export type ReviewMeta = z.infer<typeof reviewMetaSchema>;
export type BuildReviewDeps = {
provider: LlmProvider;
nerveRoot: string;
};
export function buildReviewRole({ provider, nerveRoot }: BuildReviewDeps) {
return createHermesRole<ReviewMeta>({
prompt: async (threadId) => reviewPrompt({ threadId, nerveRoot }),
extract: { provider, schema: reviewMetaSchema },
});
}

View File

@ -1,20 +1,6 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createHermesRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod"; import { z } from "zod";
import { testPrompt } from "./prompt.js";
export const testMetaSchema = z.object({ export const testMetaSchema = z.object({
passed: z.boolean().describe("true if all test commands passed"), passed: z.boolean().describe("true if all test commands passed"),
}); });
export type TestMeta = z.infer<typeof testMetaSchema>; export type TestMeta = z.infer<typeof testMetaSchema>;
export type BuildTestDeps = {
provider: LlmProvider;
};
export function buildTestRole({ provider }: BuildTestDeps) {
return createHermesRole<TestMeta>({
prompt: async (threadId) => testPrompt({ threadId }),
extract: { provider, schema: testMetaSchema },
});
}