feat: add workflow-generator meta-workflow (Issue #99)
4-role workflow (analyst → architect → coder → reviewer) that generates
new workflows from natural language descriptions. Uses cursorAgent for
analysis/design/code generation and llmExtract for structured extraction.
小橘 🍊(NEKO Team)
This commit is contained in:
parent
9a3c50c257
commit
7bfb24c2c1
14
nerve.yaml
14
nerve.yaml
@ -10,11 +10,22 @@ senses:
|
||||
throttle: 30s
|
||||
timeout: 30s
|
||||
grace_period: null
|
||||
hermes-session-message-stats:
|
||||
group: hermes
|
||||
throttle: 30s
|
||||
timeout: 60s
|
||||
grace_period: null
|
||||
|
||||
workflows:
|
||||
sense-generator:
|
||||
concurrency: 1
|
||||
overflow: drop
|
||||
workflow-generator:
|
||||
concurrency: 1
|
||||
overflow: drop
|
||||
pr-summarizer:
|
||||
concurrency: 1
|
||||
overflow: drop
|
||||
|
||||
reflexes:
|
||||
- kind: sense
|
||||
@ -23,3 +34,6 @@ reflexes:
|
||||
- kind: sense
|
||||
sense: hermes-gateway-health
|
||||
interval: 2m
|
||||
- kind: sense
|
||||
sense: hermes-session-message-stats
|
||||
interval: 15m
|
||||
|
||||
690
workflows/workflow-generator/index.ts
Normal file
690
workflows/workflow-generator/index.ts
Normal file
@ -0,0 +1,690 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
RoleResult,
|
||||
StartStep,
|
||||
WorkflowDefinition,
|
||||
WorkflowMessage,
|
||||
} from "@uncaged/nerve-core";
|
||||
import { END, parseNerveConfig } from "@uncaged/nerve-core";
|
||||
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
|
||||
import {
|
||||
cursorAgent,
|
||||
isDryRun,
|
||||
llmExtract,
|
||||
nerveAgentContext,
|
||||
readNerveYaml,
|
||||
spawnSafe,
|
||||
} from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
|
||||
const HOME = process.env.HOME ?? "/home/azureuser";
|
||||
const NERVE_ROOT = join(HOME, ".uncaged-nerve");
|
||||
const WORKFLOWS_DIR = join(NERVE_ROOT, "workflows");
|
||||
|
||||
function getNerveYaml(): string {
|
||||
const result = readNerveYaml({ nerveRoot: NERVE_ROOT });
|
||||
return result.ok ? result.value : "# nerve.yaml unavailable";
|
||||
}
|
||||
|
||||
async function cfgGet(key: string): Promise<string | null> {
|
||||
const result = await spawnSafe("cfg", ["get", key], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return null;
|
||||
}
|
||||
return result.value.stdout.trim() || null;
|
||||
}
|
||||
|
||||
async function resolveDashScopeProvider(): Promise<{
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
} | null> {
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY ?? (await cfgGet("DASHSCOPE_API_KEY"));
|
||||
const baseUrl = process.env.DASHSCOPE_BASE_URL ?? (await cfgGet("DASHSCOPE_BASE_URL"));
|
||||
const model = process.env.DASHSCOPE_MODEL ?? (await cfgGet("DASHSCOPE_MODEL")) ?? "qwen-plus";
|
||||
if (!apiKey || !baseUrl) {
|
||||
return null;
|
||||
}
|
||||
return { apiKey, baseUrl, model };
|
||||
}
|
||||
|
||||
function formatSpawnFailure(error: SpawnError): string {
|
||||
if (error.kind === "spawn_failed") {
|
||||
return error.message;
|
||||
}
|
||||
if (error.kind === "timeout") {
|
||||
return `timeout (stdout=${error.stdout.slice(0, 200)})`;
|
||||
}
|
||||
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 400)}`;
|
||||
}
|
||||
|
||||
function buildSenseGeneratorReference(): string {
|
||||
const ref = join(WORKFLOWS_DIR, "sense-generator", "index.ts");
|
||||
if (!existsSync(ref)) {
|
||||
return "(reference file workflows/sense-generator/index.ts not found)";
|
||||
}
|
||||
return readFileSync(ref, "utf-8");
|
||||
}
|
||||
|
||||
function lastMetaForRole<M>(messages: WorkflowMessage[], role: string): M | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === role) {
|
||||
return messages[i].meta as M;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const roleEntrySchema = z
|
||||
.object({
|
||||
name: z.string().describe("Role key / identifier in kebab-case or short snake name"),
|
||||
description: z.string().describe("What this role does in one or two sentences"),
|
||||
responsibilities: z.string().describe("Concrete responsibilities, inputs, and outputs for this role"),
|
||||
})
|
||||
.describe("One role in the generated workflow");
|
||||
|
||||
const analystExtractSchema = z
|
||||
.object({
|
||||
workflowName: z
|
||||
.string()
|
||||
.describe("kebab-case package directory name under workflows/, e.g. 'ticket-triage'"),
|
||||
roles: z.array(roleEntrySchema).describe("Planned roles for the new workflow"),
|
||||
moderatorFlow: z.string().describe("How the moderator should route between roles; start and exit conditions"),
|
||||
externalDeps: z
|
||||
.string()
|
||||
.describe("External tools, CLIs, HTTP APIs, or services the workflow must integrate with"),
|
||||
dataFlow: z
|
||||
.string()
|
||||
.describe("How data moves between roles: what each step consumes and produces in content/meta"),
|
||||
})
|
||||
.describe("Structured workflow specification extracted from the analysis");
|
||||
|
||||
type AnalystMetaItem = {
|
||||
name: string;
|
||||
description: string;
|
||||
responsibilities: string;
|
||||
};
|
||||
|
||||
type WorkflowGenMeta = {
|
||||
analyst: {
|
||||
userPrompt: string;
|
||||
analysis: string;
|
||||
workflowName: string;
|
||||
roles: AnalystMetaItem[];
|
||||
moderatorFlow: string;
|
||||
externalDeps: string;
|
||||
dataFlow: string;
|
||||
};
|
||||
architect: { workflowName: string; design: string };
|
||||
coder: {
|
||||
workflowName: string;
|
||||
files: { indexTs: boolean; packageJson: boolean; tsconfigJson: boolean };
|
||||
cursorOutput: string;
|
||||
};
|
||||
reviewer: {
|
||||
passed: boolean;
|
||||
workflowName: string;
|
||||
reason: string;
|
||||
attempt: number;
|
||||
validationLog: string;
|
||||
};
|
||||
};
|
||||
|
||||
const emptyAnalystMeta = (userContent: string): WorkflowGenMeta["analyst"] => ({
|
||||
userPrompt: userContent,
|
||||
analysis: "",
|
||||
workflowName: "",
|
||||
roles: [],
|
||||
moderatorFlow: "",
|
||||
externalDeps: "",
|
||||
dataFlow: "",
|
||||
});
|
||||
|
||||
function verifyNerveWorkflowEntry(workflowName: string): { ok: true } | { ok: false; reason: string } {
|
||||
const readResult = readNerveYaml({ nerveRoot: NERVE_ROOT });
|
||||
if (!readResult.ok) {
|
||||
return { ok: false, reason: `readNerveYaml: ${readResult.error.message}` };
|
||||
}
|
||||
const parsed = parseNerveConfig(readResult.value);
|
||||
if (!parsed.ok) {
|
||||
return { ok: false, reason: `parseNerveConfig: ${parsed.error.message}` };
|
||||
}
|
||||
if (parsed.value.workflows[workflowName] === undefined) {
|
||||
return { ok: false, reason: `nerve.yaml has no workflows.${workflowName} entry` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function scanGeneratedCodePitfalls(source: string): string[] {
|
||||
const issues: string[] = [];
|
||||
if (/\bawait\s+import\s*\(/.test(source)) {
|
||||
issues.push(
|
||||
"Uses await import() — only allowed in sense-runtime / workflow-worker with a documented comment",
|
||||
);
|
||||
}
|
||||
if (/\bimport\s*\(\s*["'`]/.test(source) && !source.includes("Dynamic import required")) {
|
||||
issues.push("Dynamic import() without documented exception comment");
|
||||
}
|
||||
if (/\bexport\s+default\s+/.test(source) === false) {
|
||||
issues.push("Missing default export of WorkflowDefinition (engine loads the default export)");
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function runReviewerValidation(
|
||||
workflowDir: string,
|
||||
workflowName: string,
|
||||
dry: boolean,
|
||||
): Promise<{ ok: true; log: string } | { ok: false; log: string; reason: string }> {
|
||||
const logParts: string[] = [];
|
||||
|
||||
const indexPath = join(workflowDir, "index.ts");
|
||||
const pkgPath = join(workflowDir, "package.json");
|
||||
const tsconfigPath = join(workflowDir, "tsconfig.json");
|
||||
if (!existsSync(indexPath) || !existsSync(pkgPath) || !existsSync(tsconfigPath)) {
|
||||
const miss: string[] = [];
|
||||
if (!existsSync(indexPath)) miss.push("index.ts");
|
||||
if (!existsSync(pkgPath)) miss.push("package.json");
|
||||
if (!existsSync(tsconfigPath)) miss.push("tsconfig.json");
|
||||
return { ok: false, log: "", reason: `Missing required file(s): ${miss.join(", ")}` };
|
||||
}
|
||||
|
||||
const source = readFileSync(indexPath, "utf-8");
|
||||
const pitfalls = scanGeneratedCodePitfalls(source);
|
||||
if (pitfalls.length > 0) {
|
||||
const pitfallText = pitfalls.join("\n");
|
||||
logParts.push(`=== static checks ===\n${pitfallText}`);
|
||||
return { ok: false, log: logParts.join("\n\n"), reason: pitfallText };
|
||||
}
|
||||
|
||||
const tsc = await spawnSafe("npx", ["tsc", "--noEmit"], {
|
||||
cwd: workflowDir,
|
||||
env: null,
|
||||
timeoutMs: 300_000,
|
||||
dryRun: dry,
|
||||
});
|
||||
if (!tsc.ok) {
|
||||
const msg = formatSpawnFailure(tsc.error);
|
||||
logParts.push(`=== npx tsc --noEmit ===\n${msg}`);
|
||||
return { ok: false, log: logParts.join("\n\n"), reason: `Typecheck failed: ${msg}` };
|
||||
}
|
||||
const tscOut = tsc.value.stderr.trim() || tsc.value.stdout.trim() || "(no output)";
|
||||
logParts.push(`=== npx tsc --noEmit ===\n${tscOut}`);
|
||||
|
||||
const nerveCheck = verifyNerveWorkflowEntry(workflowName);
|
||||
if (!nerveCheck.ok) {
|
||||
logParts.push(`=== nerve.yaml ===\n${nerveCheck.reason}`);
|
||||
return {
|
||||
ok: false,
|
||||
log: logParts.join("\n\n"),
|
||||
reason: `nerve.yaml: ${nerveCheck.reason}`,
|
||||
};
|
||||
}
|
||||
logParts.push(`=== nerve.yaml ===\nworkflows.${workflowName} is present.`);
|
||||
|
||||
const importLines = source.split("\n").filter((l) => /^\s*import\s/.test(l));
|
||||
logParts.push(`=== import lines ===\n${importLines.join("\n")}`);
|
||||
|
||||
return { ok: true, log: logParts.join("\n\n") };
|
||||
}
|
||||
|
||||
const workflow: WorkflowDefinition<WorkflowGenMeta> = {
|
||||
name: "workflow-generator",
|
||||
|
||||
roles: {
|
||||
async analyst(
|
||||
start: StartStep,
|
||||
_messages: WorkflowMessage[],
|
||||
): Promise<RoleResult<WorkflowGenMeta["analyst"]>> {
|
||||
const dry = isDryRun(start);
|
||||
const userInput = start.content;
|
||||
const empty = emptyAnalystMeta(userInput);
|
||||
|
||||
const provider = await resolveDashScopeProvider();
|
||||
if (provider === null) {
|
||||
return {
|
||||
content:
|
||||
"Cannot run analyst: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or configure via `cfg get`), " +
|
||||
"and optionally DASHSCOPE_MODEL.",
|
||||
meta: empty,
|
||||
};
|
||||
}
|
||||
|
||||
const askPrompt = `You are analyzing a user request to build a new Nerve **workflow** (multi-role automaton with a moderator).
|
||||
|
||||
${nerveAgentContext}
|
||||
|
||||
User's natural language description:
|
||||
${userInput}
|
||||
|
||||
Nerve root: ${NERVE_ROOT}
|
||||
Target workflows live under: ${WORKFLOWS_DIR}/<workflow-name>/
|
||||
|
||||
## Your task
|
||||
- Clarify the goal, constraints, and success criteria.
|
||||
- Identify a good kebab-case workflow package name.
|
||||
- Propose a role breakdown: what each role should do, in order.
|
||||
- Describe how a moderator should route between roles and when to end.
|
||||
- List external tools/APIs and how data should flow in \`content\` vs \`meta\` between roles.
|
||||
|
||||
Current nerve.yaml (for context only; do not edit here):
|
||||
\`\`\`yaml
|
||||
${getNerveYaml()}
|
||||
\`\`\`
|
||||
|
||||
For reference, here is a complete existing workflow (patterns to mirror, not to copy literally):
|
||||
\`\`\`ts
|
||||
${buildSenseGeneratorReference().slice(0, 18_000)}
|
||||
\`\`\`
|
||||
|
||||
Output a thorough analysis in markdown. Do not write final implementation code.`;
|
||||
|
||||
const planResult = await cursorAgent({
|
||||
prompt: askPrompt,
|
||||
mode: "ask",
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: null,
|
||||
dryRun: dry,
|
||||
});
|
||||
if (!planResult.ok) {
|
||||
return {
|
||||
content: `cursor-agent failed: ${formatSpawnFailure(planResult.error)}`,
|
||||
meta: { ...empty, analysis: "" },
|
||||
};
|
||||
}
|
||||
const analysis = planResult.value;
|
||||
|
||||
const extracted = await llmExtract({
|
||||
text: analysis,
|
||||
schema: analystExtractSchema,
|
||||
provider,
|
||||
dryRun: dry,
|
||||
});
|
||||
if (!extracted.ok) {
|
||||
return {
|
||||
content: `${analysis}\n\n[llmExtract error] ${JSON.stringify(extracted.error)}`,
|
||||
meta: {
|
||||
userPrompt: userInput,
|
||||
analysis,
|
||||
workflowName: "",
|
||||
roles: [],
|
||||
moderatorFlow: "",
|
||||
externalDeps: "",
|
||||
dataFlow: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const e = extracted.value;
|
||||
const summary =
|
||||
`## Analysis\n\n${analysis}\n\n` +
|
||||
`## Structured spec\n\n` +
|
||||
`**workflowName:** ${e.workflowName}\n\n` +
|
||||
`**moderatorFlow:**\n${e.moderatorFlow}\n\n` +
|
||||
`**externalDeps:**\n${e.externalDeps}\n\n` +
|
||||
`**dataFlow:**\n${e.dataFlow}\n\n` +
|
||||
`**roles:**\n` +
|
||||
e.roles
|
||||
.map(
|
||||
(r, i) =>
|
||||
`${i + 1}. **${r.name}** — ${r.description}\n - ${r.responsibilities}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
return {
|
||||
content: summary,
|
||||
meta: {
|
||||
userPrompt: userInput,
|
||||
analysis,
|
||||
workflowName: e.workflowName,
|
||||
roles: e.roles,
|
||||
moderatorFlow: e.moderatorFlow,
|
||||
externalDeps: e.externalDeps,
|
||||
dataFlow: e.dataFlow,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async architect(
|
||||
start: StartStep,
|
||||
messages: WorkflowMessage[],
|
||||
): Promise<RoleResult<WorkflowGenMeta["architect"]>> {
|
||||
const dry = isDryRun(start);
|
||||
const last = messages[messages.length - 1];
|
||||
const spec = last.meta as WorkflowGenMeta["analyst"];
|
||||
const wfName = spec.workflowName.trim();
|
||||
|
||||
if (wfName.length === 0) {
|
||||
return {
|
||||
content: "Architect skipped — analyst did not produce a workflow name.",
|
||||
meta: { workflowName: "", design: "" },
|
||||
};
|
||||
}
|
||||
|
||||
const rolesText = spec.roles
|
||||
.map(
|
||||
(r) =>
|
||||
`### ${r.name}\n- **description:** ${r.description}\n- **responsibilities:** ${r.responsibilities}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
const designPrompt = `You are the architect for a new Nerve **workflow** (multi-role state machine with a \`WorkflowDefinition\` and moderator).
|
||||
|
||||
${nerveAgentContext}
|
||||
|
||||
Target package directory: ${WORKFLOWS_DIR}/${wfName}/
|
||||
|
||||
## Analyst output
|
||||
|
||||
**User prompt:**
|
||||
${spec.userPrompt}
|
||||
|
||||
**Moderator / routing (from analyst):**
|
||||
${spec.moderatorFlow}
|
||||
|
||||
**External dependencies:**
|
||||
${spec.externalDeps}
|
||||
|
||||
**Data flow:**
|
||||
${spec.dataFlow}
|
||||
|
||||
**Roles (planned):**
|
||||
${rolesText}
|
||||
|
||||
## Your task (design document only, no file contents)
|
||||
|
||||
Produce an implementation-ready design in markdown:
|
||||
|
||||
1. **Meta type (TypeScript)**
|
||||
- A concrete \`type WorkflowMeta = { ... }\` using \`type\` (not interface), no optional \`?:\` — use \`T | null\` for nullable fields.
|
||||
- One entry per role with the exact fields each role will put in \`RoleResult\` meta.
|
||||
|
||||
2. **Role functions**
|
||||
- For each role: parameters (\`StartStep\`, \`WorkflowMessage[]\`), return \`RoleResult<…>\`, what to read from \`start\` / prior messages, what to put in \`content\` vs \`meta\`.
|
||||
|
||||
3. **Moderator**
|
||||
- Pseudocode for \`moderator(context)\` using \`END\` from \`@uncaged/nerve-core\`, edge conditions, and error paths (routed in moderator, not via process exit).
|
||||
|
||||
4. **Error handling**
|
||||
- How each role reports recoverable failure (content + meta) and how the moderator steers the thread.
|
||||
|
||||
5. **Imports**
|
||||
- List required imports from \`@uncaged/nerve-core\` and \`@uncaged/nerve-workflow-utils\` only as needed by the final code.
|
||||
|
||||
6. **Files the coder will write**
|
||||
- \`${WORKFLOWS_DIR}/${wfName}/index.ts\` — \`export default\` a \`WorkflowDefinition<YourMeta>\`
|
||||
- \`${WORKFLOWS_DIR}/${wfName}/package.json\` with \`"type": "module"\` and dependencies (include \`zod\` if the workflow parses structured data)
|
||||
- \`${WORKFLOWS_DIR}/${wfName}/tsconfig.json\` — if \`${NERVE_ROOT}/tsconfig.workflow.base.json\` exists, extend it; else a strict NodeNext \`noEmit\` project
|
||||
|
||||
7. **nerve.yaml**
|
||||
- The coder must add a \`workflows:${wfName}\` block to \`${NERVE_ROOT}/nerve.yaml\` (concurrency, overflow) without removing existing keys.
|
||||
|
||||
8. **Nerve code rules to preserve in the generated \`index.ts\`**
|
||||
- No dynamic \`import()\` in the generated workflow (except documented exceptions in engine loaders).
|
||||
- \`type\` over \`interface\`, \`function\` over \`class\` for the workflow’s own code.
|
||||
|
||||
## Reference (meta-workflow style)
|
||||
\`\`\`ts
|
||||
${buildSenseGeneratorReference().slice(0, 22_000)}
|
||||
\`\`\`
|
||||
|
||||
Current nerve.yaml:
|
||||
\`\`\`yaml
|
||||
${getNerveYaml()}
|
||||
\`\`\`
|
||||
|
||||
Output ONLY the design markdown.`;
|
||||
|
||||
const planResult = await cursorAgent({
|
||||
prompt: designPrompt,
|
||||
mode: "ask",
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: null,
|
||||
dryRun: dry,
|
||||
});
|
||||
if (!planResult.ok) {
|
||||
return {
|
||||
content: `cursor-agent failed: ${formatSpawnFailure(planResult.error)}`,
|
||||
meta: { workflowName: wfName, design: "" },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: planResult.value,
|
||||
meta: { workflowName: wfName, design: planResult.value },
|
||||
};
|
||||
},
|
||||
|
||||
async coder(
|
||||
start: StartStep,
|
||||
messages: WorkflowMessage[],
|
||||
): Promise<RoleResult<WorkflowGenMeta["coder"]>> {
|
||||
const dry = isDryRun(start);
|
||||
const analystMeta = lastMetaForRole<WorkflowGenMeta["analyst"]>(messages, "analyst");
|
||||
const architectMeta = lastMetaForRole<WorkflowGenMeta["architect"]>(messages, "architect");
|
||||
const priorReviewer = lastMetaForRole<WorkflowGenMeta["reviewer"]>(messages, "reviewer");
|
||||
|
||||
if (analystMeta === null || architectMeta === null) {
|
||||
return {
|
||||
content: "coder: missing analyst or architect message in history",
|
||||
meta: {
|
||||
workflowName: "",
|
||||
files: { indexTs: false, packageJson: false, tsconfigJson: false },
|
||||
cursorOutput: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const wfName = analystMeta.workflowName.trim();
|
||||
if (wfName.length === 0) {
|
||||
return {
|
||||
content: "coder: empty workflow name",
|
||||
meta: {
|
||||
workflowName: "",
|
||||
files: { indexTs: false, packageJson: false, tsconfigJson: false },
|
||||
cursorOutput: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fixSection =
|
||||
priorReviewer !== null && priorReviewer.passed === false
|
||||
? `\n\n## Previous review (address these before anything else)\n${priorReviewer.reason}\n\nFull validation log:\n${priorReviewer.validationLog}\n`
|
||||
: "";
|
||||
|
||||
const codePrompt = `You are implementing a new Nerve workflow package at ${WORKFLOWS_DIR}/${wfName}/.
|
||||
|
||||
## Architect design (authoritative for structure)
|
||||
${architectMeta.design}
|
||||
|
||||
## Analyst structured fields
|
||||
${JSON.stringify(
|
||||
{
|
||||
workflowName: analystMeta.workflowName,
|
||||
userPrompt: analystMeta.userPrompt,
|
||||
roles: analystMeta.roles,
|
||||
moderatorFlow: analystMeta.moderatorFlow,
|
||||
externalDeps: analystMeta.externalDeps,
|
||||
dataFlow: analystMeta.dataFlow,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
${fixSection}
|
||||
|
||||
## Files to create or update
|
||||
1. \`${WORKFLOWS_DIR}/${wfName}/index.ts\` — \`export default\` a \`WorkflowDefinition\` (same style as sense-generator: named imports, default export at end).
|
||||
2. \`${WORKFLOWS_DIR}/${wfName}/package.json\` — \`"type": "module"\`, dependencies on \`@uncaged/nerve-core\`, \`@uncaged/nerve-workflow-utils\`, \`zod\` if used; add \`typescript\` in devDependencies so \`npx tsc --noEmit\` works in that directory.
|
||||
3. \`${WORKFLOWS_DIR}/${wfName}/tsconfig.json\` — strict, \`module\`/\`moduleResolution\` NodeNext, \`noEmit: true\`, include all \`.ts\` in the folder.
|
||||
|
||||
4. **Register the workflow** — merge a new block into the existing \`${NERVE_ROOT}/nerve.yaml\` under the top-level \`workflows:\` key:
|
||||
\`\`\`yaml
|
||||
${wfName}:
|
||||
concurrency: 1
|
||||
overflow: drop
|
||||
\`\`\`
|
||||
Do not remove or overwrite unrelated senses, reflexes, or other workflow entries. Preserve valid YAML.
|
||||
|
||||
## Implementation patterns (when applicable)
|
||||
- \`resolveDashScopeProvider\`, \`nerveAgentContext\`, \`readNerveYaml\`, \`cursorAgent\`, \`llmExtract\`, \`spawnSafe\`, \`formatSpawnFailure\` from \`@uncaged/nerve-workflow-utils\` as in sense-generator.
|
||||
- No dynamic \`import()\` in the new workflow code.
|
||||
|
||||
## Reference workflow
|
||||
\`\`\`ts
|
||||
${buildSenseGeneratorReference().slice(0, 20_000)}
|
||||
\`\`\`
|
||||
|
||||
Current nerve.yaml (merge carefully; keep all existing content):
|
||||
\`\`\`yaml
|
||||
${getNerveYaml()}
|
||||
\`\`\`
|
||||
|
||||
Implement now.`;
|
||||
|
||||
const agentResult = await cursorAgent({
|
||||
prompt: codePrompt,
|
||||
mode: "default",
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: null,
|
||||
dryRun: dry,
|
||||
});
|
||||
|
||||
const workflowDir = join(WORKFLOWS_DIR, wfName);
|
||||
const files = {
|
||||
indexTs: existsSync(join(workflowDir, "index.ts")),
|
||||
packageJson: existsSync(join(workflowDir, "package.json")),
|
||||
tsconfigJson: existsSync(join(workflowDir, "tsconfig.json")),
|
||||
};
|
||||
|
||||
if (!agentResult.ok) {
|
||||
const errText = `cursor-agent failed: ${formatSpawnFailure(agentResult.error)}`;
|
||||
return {
|
||||
content: errText,
|
||||
meta: { workflowName: wfName, files, cursorOutput: errText },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: agentResult.value,
|
||||
meta: { workflowName: wfName, files, cursorOutput: agentResult.value },
|
||||
};
|
||||
},
|
||||
|
||||
async reviewer(
|
||||
start: StartStep,
|
||||
messages: WorkflowMessage[],
|
||||
): Promise<RoleResult<WorkflowGenMeta["reviewer"]>> {
|
||||
const dry = isDryRun(start);
|
||||
const last = messages[messages.length - 1];
|
||||
const { workflowName, files } = last.meta as WorkflowGenMeta["coder"];
|
||||
|
||||
const attempt = messages.filter((m) => m.role === "reviewer").length + 1;
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!files.indexTs) missing.push("index.ts");
|
||||
if (!files.packageJson) missing.push("package.json");
|
||||
if (!files.tsconfigJson) missing.push("tsconfig.json");
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
content: `FAIL — missing: ${missing.join(", ")}`,
|
||||
meta: {
|
||||
passed: false,
|
||||
workflowName,
|
||||
reason: `Missing required file(s): ${missing.join(", ")}`,
|
||||
attempt,
|
||||
validationLog: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const name = workflowName.trim();
|
||||
if (name.length === 0) {
|
||||
return {
|
||||
content: "FAIL — empty workflow name in coder meta",
|
||||
meta: {
|
||||
passed: false,
|
||||
workflowName: "",
|
||||
reason: "Coder meta had empty workflowName",
|
||||
attempt,
|
||||
validationLog: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const workflowDir = join(WORKFLOWS_DIR, name);
|
||||
const checks = await runReviewerValidation(workflowDir, name, dry);
|
||||
|
||||
if (!checks.ok) {
|
||||
return {
|
||||
content: `FAIL — ${checks.reason}`,
|
||||
meta: {
|
||||
passed: false,
|
||||
workflowName: name,
|
||||
reason: checks.reason,
|
||||
attempt,
|
||||
validationLog: checks.log,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: `PASS — typecheck and nerve.yaml check OK.\n\n${checks.log.slice(0, 8000)}`,
|
||||
meta: {
|
||||
passed: true,
|
||||
workflowName: name,
|
||||
reason: "npx tsc --noEmit passed and nerve.yaml contains the workflow entry",
|
||||
attempt,
|
||||
validationLog: checks.log,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
moderator(context) {
|
||||
if (context.steps.length === 0) {
|
||||
return "analyst";
|
||||
}
|
||||
|
||||
const last = context.steps[context.steps.length - 1];
|
||||
|
||||
if (last.role === "analyst") {
|
||||
if (last.meta.workflowName.trim().length === 0) {
|
||||
return END;
|
||||
}
|
||||
return "architect";
|
||||
}
|
||||
|
||||
if (last.role === "architect") {
|
||||
if (last.meta.workflowName.trim().length === 0 || last.meta.design.trim().length === 0) {
|
||||
return END;
|
||||
}
|
||||
return "coder";
|
||||
}
|
||||
|
||||
if (last.role === "coder") {
|
||||
return "reviewer";
|
||||
}
|
||||
|
||||
if (last.role === "reviewer") {
|
||||
if (last.meta.passed) {
|
||||
return END;
|
||||
}
|
||||
if (last.meta.attempt < 3) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
}
|
||||
|
||||
return END;
|
||||
},
|
||||
};
|
||||
|
||||
export default workflow;
|
||||
21
workflows/workflow-generator/package.json
Normal file
21
workflows/workflow-generator/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "workflow-generator-workflow",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "latest",
|
||||
"@uncaged/nerve-workflow-utils": "latest",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@uncaged/nerve-daemon": "link:../../../repos/nerve/packages/daemon",
|
||||
"@uncaged/nerve-core": "link:../../../repos/nerve/packages/core",
|
||||
"@uncaged/nerve-workflow-utils": "link:../../../repos/nerve/packages/workflow-utils"
|
||||
}
|
||||
}
|
||||
}
|
||||
49
workflows/workflow-generator/pnpm-lock.yaml
generated
Normal file
49
workflows/workflow-generator/pnpm-lock.yaml
generated
Normal file
@ -0,0 +1,49 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
'@uncaged/nerve-daemon': link:../../../repos/nerve/packages/daemon
|
||||
'@uncaged/nerve-core': link:../../../repos/nerve/packages/core
|
||||
'@uncaged/nerve-workflow-utils': link:../../../repos/nerve/packages/workflow-utils
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/nerve-core':
|
||||
specifier: link:../../../repos/nerve/packages/core
|
||||
version: link:../../../repos/nerve/packages/core
|
||||
'@uncaged/nerve-workflow-utils':
|
||||
specifier: link:../../../repos/nerve/packages/workflow-utils
|
||||
version: link:../../../repos/nerve/packages/workflow-utils
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.17
|
||||
|
||||
packages:
|
||||
|
||||
'@types/node@22.19.17':
|
||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@types/node@22.19.17':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
zod@4.3.6: {}
|
||||
13
workflows/workflow-generator/tsconfig.json
Normal file
13
workflows/workflow-generator/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user