Compare commits

..

3 Commits

Author SHA1 Message Date
e05c71d6b0 refactor(sense-generator): use createCursorRole factory, slim meta to routing-only
- planner/coder: replaced 80+ lines hand-written agent calls with createCursorRole()
- SenseMeta slimmed to routing signals only (senseName, filesCreated, passed/attempt)
- Roles read context from thread via nerve thread <id>, not from previous role's meta
- tester stays hand-written (pure CLI logic)
- Re-exported spawnSafe from workflow-utils for helper use

Refs uncaged/nerve#210

小橘 🍊(NEKO Team)
2026-04-28 02:22:38 +00:00
8ff6003a75 refactor(sense-generator): use createCursorRole and slim SenseMeta
Replace hand-written planner and coder with createCursorRole from nerve-workflow-utils. Prompts instruct reading the Nerve thread via nerve thread show. Extract uses resolveDashScopeProvider. SenseMeta keeps routing-only fields; tester remains hand-written with filesystem and smoke checks.

Made-with: Cursor
2026-04-28 02:20:01 +00:00
c5ea790447 refactor(workflow-generator): simplify roles — merge analyst+architect→planner, add coder⇄tester loop, hermes committer
Refs #143

小橘 <xiaoju@shazhou.work>
2026-04-25 10:31:33 +00:00
7 changed files with 1944 additions and 1379 deletions

View File

@ -32,6 +32,9 @@ workflows:
hello-world: hello-world:
concurrency: 1 concurrency: 1
overflow: drop overflow: drop
gitea-issue-solver:
concurrency: 1
overflow: drop
reflexes: reflexes:
- kind: sense - kind: sense

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
{
"name": "gitea-issue-solver-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",
"typescript": "^5.7.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"
}
}
}

View File

@ -0,0 +1,59 @@
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
typescript:
specifier: ^5.7.0
version: 5.9.3
packages:
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
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
typescript@5.9.3: {}
undici-types@6.21.0: {}
zod@4.3.6: {}

View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["./**/*.ts"]
}

View File

@ -5,25 +5,28 @@ import type {
WorkflowMessage, WorkflowMessage,
} from "@uncaged/nerve-core"; } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core"; import { END } from "@uncaged/nerve-core";
import { createCursorRole, spawnSafe } from "@uncaged/nerve-workflow-utils";
import type { SpawnError } from "@uncaged/nerve-workflow-utils"; import type { SpawnError } from "@uncaged/nerve-workflow-utils";
import {
cursorAgent,
llmExtract,
nerveAgentContext,
readNerveYaml,
spawnSafe,
} from "@uncaged/nerve-workflow-utils";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { z } from "zod"; import { z } from "zod";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const HOME = process.env.HOME ?? "/home/azureuser"; const HOME = process.env.HOME ?? "/home/azureuser";
const NERVE_ROOT = join(HOME, ".uncaged-nerve"); const NERVE_ROOT = join(HOME, ".uncaged-nerve");
const SENSES_DIR = join(NERVE_ROOT, "senses"); const SENSES_DIR = join(NERVE_ROOT, "senses");
function getNerveYaml(): string { // ---------------------------------------------------------------------------
const result = readNerveYaml({ nerveRoot: NERVE_ROOT }); // Helpers
return result.ok ? result.value : "# nerve.yaml unavailable"; // ---------------------------------------------------------------------------
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)}`;
} }
async function cfgGet(key: string): Promise<string | null> { async function cfgGet(key: string): Promise<string | null> {
@ -32,9 +35,7 @@ async function cfgGet(key: string): Promise<string | null> {
env: null, env: null,
timeoutMs: 10_000, timeoutMs: 10_000,
}); });
if (!result.ok) { if (!result.ok) return null;
return null;
}
return result.value.stdout.trim() || null; return result.value.stdout.trim() || null;
} }
@ -45,105 +46,19 @@ async function resolveDashScopeProvider(): Promise<{
} | null> { } | null> {
const apiKey = process.env.DASHSCOPE_API_KEY ?? (await cfgGet("DASHSCOPE_API_KEY")); 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 baseUrl = process.env.DASHSCOPE_BASE_URL ?? (await cfgGet("DASHSCOPE_BASE_URL"));
const model = const model = process.env.DASHSCOPE_MODEL ?? (await cfgGet("DASHSCOPE_MODEL")) ?? "qwen-plus";
process.env.DASHSCOPE_MODEL ?? (await cfgGet("DASHSCOPE_MODEL")) ?? "qwen-plus"; if (!apiKey || !baseUrl) return null;
if (!apiKey || !baseUrl) {
return null;
}
return { apiKey, baseUrl, model }; return { apiKey, baseUrl, model };
} }
function formatSpawnFailure(error: SpawnError): string { function getNerveYaml(): string {
if (error.kind === "spawn_failed") { try {
return error.message; return readFileSync(join(NERVE_ROOT, "nerve.yaml"), "utf-8");
} catch {
return "# nerve.yaml unavailable";
} }
if (error.kind === "timeout") {
return `timeout (stdout=${error.stdout.slice(0, 200)})`;
}
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 400)}`;
} }
/**
* Run the same checks the workflow used to ask Hermes to perform, but locally.
* Hermes chat often returns UI prose instead of shell output, which caused false failures.
*/
async function runSenseSmokeTest(senseName: string): Promise<{ ok: boolean; log: string; reason: string }> {
const logParts: string[] = [];
const runNerve = async (args: string[]): Promise<{ ok: true; out: string } | { ok: false; err: string }> => {
const result = await spawnSafe("nerve", args, {
cwd: NERVE_ROOT,
env: null,
timeoutMs: 300_000,
});
if (!result.ok) {
return { ok: false, err: formatSpawnFailure(result.error) };
}
return { ok: true, out: result.value.stdout };
};
const statusRun = await runNerve(["status"]);
if (!statusRun.ok) {
return {
ok: false,
log: `=== nerve status ===\nERROR: ${statusRun.err}`,
reason: `Smoke test command failed: ${statusRun.err}`,
};
}
const status = statusRun.out;
logParts.push("=== nerve status ===\n" + status);
if (!status.includes(senseName)) {
return {
ok: false,
log: logParts.join("\n\n"),
reason: `Sense "${senseName}" not listed in \`nerve status\` output`,
};
}
const triggerRun = await runNerve(["sense", "trigger", senseName]);
if (!triggerRun.ok) {
logParts.push(`=== nerve sense trigger ===\nERROR: ${triggerRun.err}`);
return {
ok: false,
log: logParts.join("\n\n"),
reason: `Smoke test command failed: ${triggerRun.err}`,
};
}
logParts.push("=== nerve sense trigger ===\n" + triggerRun.out);
let lastQuery = "";
for (let i = 0; i < 25; i++) {
const sleepR = await spawnSafe("sleep", ["1"], { cwd: NERVE_ROOT, env: null, timeoutMs: 10_000 });
if (!sleepR.ok) {
logParts.push(`=== sleep (attempt ${i + 1}) ===\nERROR: ${formatSpawnFailure(sleepR.error)}`);
}
const queryRun = await runNerve(["sense", "query", senseName]);
if (!queryRun.ok) {
logParts.push(`=== nerve sense query (attempt ${i + 1}) ===\nERROR: ${queryRun.err}`);
} else {
lastQuery = queryRun.out;
logParts.push(`=== nerve sense query (attempt ${i + 1}) ===\n${lastQuery}`);
if (!lastQuery.includes("(0 rows)")) {
return {
ok: true,
log: logParts.join("\n\n"),
reason: "Trigger succeeded and query returned at least one row",
};
}
}
}
return {
ok: false,
log: logParts.join("\n\n"),
reason: lastQuery.includes("(0 rows)")
? "Query still returned 0 rows after trigger (compute error, throttle drop, or DB not written)"
: "Timed out waiting for successful sense query",
};
}
// Build context string with existing sense examples
function buildSenseExamples(): string { function buildSenseExamples(): string {
const examples: string[] = []; const examples: string[] = [];
for (const name of ["cpu-usage", "linux-system-health"]) { for (const name of ["cpu-usage", "linux-system-health"]) {
@ -170,247 +85,234 @@ function buildSenseExamples(): string {
return examples.join("\n\n---\n\n"); return examples.join("\n\n---\n\n");
} }
type SenseMeta = { async function runSenseSmokeTest(
planner: { plan: string; senseName: string; userInput: string }; senseName: string,
coder: { senseName: string; files: Record<string, boolean>; cursorOutput: string }; ): Promise<{ ok: boolean; log: string; reason: string }> {
tester: { passed: boolean; senseName: string; reason: string; attempt: number }; const logParts: string[] = [];
};
const senseMetaSchema = z const runNerve = async (
.object({ args: string[],
name: z.string().describe("kebab-case sense name, e.g. 'disk-usage'"), ): Promise<{ ok: true; out: string } | { ok: false; err: string }> => {
description: z.string().describe("One-line description of what this sense monitors"), const result = await spawnSafe("nerve", args, {
}) cwd: NERVE_ROOT,
.describe("Extract the sense name and a one-line description from the plan"); env: null,
timeoutMs: 300_000,
});
if (!result.ok) return { ok: false, err: formatSpawnFailure(result.error) };
return { ok: true, out: result.value.stdout };
};
const workflow: WorkflowDefinition<SenseMeta> = { const statusRun = await runNerve(["status"]);
name: "sense-generator", if (!statusRun.ok) {
return {
ok: false,
log: `=== nerve status ===\nERROR: ${statusRun.err}`,
reason: `Smoke test command failed: ${statusRun.err}`,
};
}
logParts.push("=== nerve status ===\n" + statusRun.out);
if (!statusRun.out.includes(senseName)) {
return {
ok: false,
log: logParts.join("\n\n"),
reason: `Sense "${senseName}" not listed in \`nerve status\` output`,
};
}
roles: { const triggerRun = await runNerve(["sense", "trigger", senseName]);
async planner( if (!triggerRun.ok) {
start: StartStep, logParts.push(`=== nerve sense trigger ===\nERROR: ${triggerRun.err}`);
_messages: WorkflowMessage[], return { ok: false, log: logParts.join("\n\n"), reason: `Trigger failed: ${triggerRun.err}` };
): Promise<RoleResult<SenseMeta["planner"]>> { }
const userInput = start.content; logParts.push("=== nerve sense trigger ===\n" + triggerRun.out);
const provider = await resolveDashScopeProvider(); let lastQuery = "";
if (provider === null) { for (let i = 0; i < 25; i++) {
await new Promise((r) => setTimeout(r, 1000));
const queryRun = await runNerve(["sense", "query", senseName]);
if (!queryRun.ok) {
logParts.push(`=== query attempt ${i + 1} ===\nERROR: ${queryRun.err}`);
} else {
lastQuery = queryRun.out;
logParts.push(`=== query attempt ${i + 1} ===\n${lastQuery}`);
if (!lastQuery.includes("(0 rows)")) {
return { return {
content: ok: true,
"Cannot run planner: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or configure via `cfg get`), " + log: logParts.join("\n\n"),
"and optionally DASHSCOPE_MODEL.", reason: "Trigger succeeded and query returned at least one row",
meta: { plan: "", senseName: "", userInput },
}; };
} }
}
}
const planPrompt = `You are planning a new Nerve sense. return {
ok: false,
log: logParts.join("\n\n"),
reason: lastQuery.includes("(0 rows)")
? "Query still returned 0 rows after trigger"
: "Timed out waiting for successful sense query",
};
}
${nerveAgentContext} // ---------------------------------------------------------------------------
// Meta — routing-only signals for the moderator
// ---------------------------------------------------------------------------
User request: ${userInput} type SenseMeta = {
Pick a good kebab-case name for this sense. planner: { senseName: string };
coder: { filesCreated: boolean };
tester: { passed: boolean; attempt: number };
};
Your job is to produce a PLAN (not code) for this sense. Output a structured plan in markdown with these sections: // ---------------------------------------------------------------------------
// Bake static context (read once at module load, not per-call)
// ---------------------------------------------------------------------------
const senseExamples = buildSenseExamples();
const nerveYaml = getNerveYaml();
// ---------------------------------------------------------------------------
// Roles
// ---------------------------------------------------------------------------
async function buildPlannerRole() {
const provider = await resolveDashScopeProvider();
if (provider === null) {
throw new Error("Cannot create planner: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
}
return createCursorRole<SenseMeta["planner"]>({
cwd: NERVE_ROOT,
mode: "ask",
prompt: async (threadId) =>
`You are planning a new Nerve sense.
Read the workflow thread for the user's request: \`nerve thread ${threadId}\`
Pick a good kebab-case name for this sense. Produce a PLAN (not code) in markdown:
## Sense Design ## Sense Design
### Name kebab-case
### Fields name, type (integer/real/text), description
### Compute Logic step-by-step, specific Node.js APIs or shell commands
### Trigger Config group, interval, throttle, timeout
### Name Reference senses:
(decide a kebab-case name) ${senseExamples}
### Fields
List every field the sense should collect, with name, type (integer/real/text), and description.
### Compute Logic
Describe step-by-step what the compute() function should do. Be specific about which Node.js APIs or shell commands to use.
### Trigger Config
- group: (suggest a group name)
- interval: (decide based on the use case, e.g. 30s, 1m, 5m)
- throttle: (suggest)
- timeout: (suggest)
Here are existing senses for reference on the format and patterns used:
${buildSenseExamples()}
Current nerve.yaml: Current nerve.yaml:
\`\`\`yaml \`\`\`yaml
${getNerveYaml()} ${nerveYaml}
\`\`\` \`\`\`
Output ONLY the plan in markdown. Be precise and implementation-ready.`; Output ONLY the plan. Be precise and implementation-ready.`,
extract: {
const planResult = await cursorAgent({ provider,
prompt: planPrompt, schema: z.object({
mode: "ask", senseName: z.string().describe("kebab-case sense name from the plan"),
cwd: NERVE_ROOT, }),
env: null,
timeoutMs: null,
});
if (!planResult.ok) {
return {
content: `cursor-agent failed: ${formatSpawnFailure(planResult.error)}`,
meta: { plan: "", senseName: "", userInput },
};
}
const plan = planResult.value;
const extracted = await llmExtract({
text: plan,
schema: senseMetaSchema,
provider,
});
if (!extracted.ok) {
return {
content: `${plan}\n\n[llmExtract error] ${JSON.stringify(extracted.error)}`,
meta: { plan, senseName: "", userInput },
};
}
return {
content: plan,
meta: { plan, senseName: extracted.value.name, userInput },
};
}, },
});
}
async coder( async function buildCoderRole() {
_start: StartStep, const provider = await resolveDashScopeProvider();
messages: WorkflowMessage[], if (provider === null) {
): Promise<RoleResult<SenseMeta["coder"]>> { throw new Error("Cannot create coder: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
const last = messages[messages.length - 1]; }
const { plan, senseName } = last.meta as { plan: string; senseName: string }; return createCursorRole<SenseMeta["coder"]>({
cwd: NERVE_ROOT,
mode: "default",
prompt: async (threadId) =>
`Read the workflow thread for the planner's sense design: \`nerve thread ${threadId}\`
const codePrompt = `You are implementing a new Nerve sense called "${senseName}" in the directory ${SENSES_DIR}/${senseName}/. Implement the sense. Create exactly:
1. The sense directory under ${SENSES_DIR}/<sense-name>/
2. index.js export async function compute(db, _peers), import schema from "./schema.ts"
3. schema.ts drizzle-orm/sqlite-core
4. migrations/0001_init.sql must match schema.ts
5. Update ${NERVE_ROOT}/nerve.yaml add sense config + reflex entry
Here is the plan: Follow the patterns from existing senses. Create all files now.`,
extract: {
${plan} provider,
schema: z.object({
You need to create exactly 3 files: filesCreated: z.boolean().describe("true if the sense files were created"),
1. \`${SENSES_DIR}/${senseName}/index.js\` — the compute() function }),
2. \`${SENSES_DIR}/${senseName}/schema.ts\` — Drizzle ORM schema
3. \`${SENSES_DIR}/${senseName}/migrations/0001_init.sql\` — SQLite migration
And UPDATE the existing file:
4. \`${NERVE_ROOT}/nerve.yaml\` — add the new sense config and reflex entry
Here are existing senses for reference follow the EXACT same patterns:
${buildSenseExamples()}
Current nerve.yaml (append to it, don't overwrite existing entries):
\`\`\`yaml
${getNerveYaml()}
\`\`\`
IMPORTANT RULES:
- index.js uses \`export async function compute(db, _peers)\` signature
- index.js imports the schema table from "./schema.ts" and uses \`await db.insert(table).values({...})\` to persist
- schema.ts uses drizzle-orm/sqlite-core imports
- migration SQL must match schema.ts exactly
- nerve.yaml: add under \`senses:\` and add a reflex under \`reflexes:\`
- Use the interval specified in the plan for the reflex
Create all files now.`;
const agentResult = await cursorAgent({
prompt: codePrompt,
mode: "default",
cwd: NERVE_ROOT,
env: null,
timeoutMs: null,
});
if (!agentResult.ok) {
const resultText = `cursor-agent failed: ${formatSpawnFailure(agentResult.error)}`;
return {
content: resultText,
meta: {
senseName,
files: { index: false, schema: false, migration: false },
cursorOutput: resultText,
},
};
}
const result = agentResult.value;
const senseDir = join(SENSES_DIR, senseName);
const files = {
index: existsSync(join(senseDir, "index.js")),
schema: existsSync(join(senseDir, "schema.ts")),
migration: existsSync(join(senseDir, "migrations", "0001_init.sql")),
};
return {
content: result,
meta: { senseName, files, cursorOutput: result },
};
}, },
});
}
async tester( // Tester: pure CLI logic — stays hand-written
_start: StartStep, async function tester(
messages: WorkflowMessage[], _start: StartStep,
): Promise<RoleResult<SenseMeta["tester"]>> { messages: WorkflowMessage[],
const last = messages[messages.length - 1]; ): Promise<RoleResult<SenseMeta["tester"]>> {
const { senseName, files } = last.meta as { senseName: string; files: Record<string, boolean> }; const attempt = messages.filter((m) => m.role === "tester").length + 1;
const attempt = messages.filter((m) => m.role === "tester").length + 1; // Get senseName from planner meta
const plannerStep = messages.find((m) => m.role === "planner");
const senseName = plannerStep
? (plannerStep.meta as SenseMeta["planner"]).senseName
: "";
const missing = Object.entries(files).filter(([, v]) => !v).map(([k]) => k); if (senseName.length === 0) {
if (missing.length > 0) { return {
return { content: "FAIL — no senseName from planner",
content: `FAIL — missing files: ${missing.join(", ")}`, meta: { passed: false, attempt },
meta: { passed: false, senseName, reason: `Missing files: ${missing.join(", ")}`, attempt }, };
}; }
}
const smoke = await runSenseSmokeTest(senseName); // Check files exist
const senseDir = join(SENSES_DIR, senseName);
const missing = [
existsSync(join(senseDir, "index.js")) ? null : "index.js",
existsSync(join(senseDir, "schema.ts")) ? null : "schema.ts",
existsSync(join(senseDir, "migrations", "0001_init.sql")) ? null : "migrations/0001_init.sql",
].filter((x) => x !== null);
if (smoke.ok) { if (missing.length > 0) {
return { return {
content: `PASS — ${smoke.reason}`, content: `FAIL — missing files: ${missing.join(", ")}`,
meta: { passed: true, senseName, reason: smoke.reason, attempt }, meta: { passed: false, attempt },
}; };
} }
return { // Smoke test
content: `FAIL — ${smoke.reason}`, const smoke = await runSenseSmokeTest(senseName);
meta: { return {
passed: false, content: `${smoke.ok ? "PASS" : "FAIL"}${smoke.reason}`,
senseName, meta: { passed: smoke.ok, attempt },
reason: `${smoke.reason}\n\n--- smoke log ---\n${smoke.log}`, };
attempt, }
},
}; // ---------------------------------------------------------------------------
// Workflow definition
// ---------------------------------------------------------------------------
async function buildWorkflow(): Promise<WorkflowDefinition<SenseMeta>> {
const plannerRole = await buildPlannerRole();
const coderRole = await buildCoderRole();
return {
name: "sense-generator",
roles: {
planner: plannerRole,
coder: coderRole,
tester,
}, },
}, moderator(context) {
if (context.steps.length === 0) return "planner";
moderator(context) { const last = context.steps[context.steps.length - 1];
if (context.steps.length === 0) { if (last.role === "planner") return "coder";
return "planner"; if (last.role === "coder") return "tester";
} if (last.role === "tester") {
if (last.meta.passed) return END;
const signal = context.steps[context.steps.length - 1]; return last.meta.attempt < 3 ? "coder" : END;
if (signal.role === "planner") {
return "coder";
}
if (signal.role === "coder") {
return "tester";
}
if (signal.role === "tester") {
const meta = signal.meta;
if (meta.passed) {
return END;
}
if (meta.attempt < 3) {
return "coder";
} }
return END; return END;
} },
};
return END; }
},
};
const workflow = await buildWorkflow();
export default workflow; export default workflow;

File diff suppressed because it is too large Load Diff