From e05c71d6b03cf24fd5973145d1b820200333b15c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Tue, 28 Apr 2026 02:22:38 +0000 Subject: [PATCH] refactor(sense-generator): use createCursorRole factory, slim meta to routing-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 , 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οΌ‰ --- nerve.yaml | 3 + workflows/gitea-issue-solver/index.ts | 1069 +++++++++++++++++++ workflows/gitea-issue-solver/package.json | 22 + workflows/gitea-issue-solver/pnpm-lock.yaml | 59 + workflows/gitea-issue-solver/tsconfig.json | 13 + workflows/sense-generator/index.ts | 486 ++++----- workflows/workflow-generator/index.ts | 14 +- 7 files changed, 1358 insertions(+), 308 deletions(-) create mode 100644 workflows/gitea-issue-solver/index.ts create mode 100644 workflows/gitea-issue-solver/package.json create mode 100644 workflows/gitea-issue-solver/pnpm-lock.yaml create mode 100644 workflows/gitea-issue-solver/tsconfig.json diff --git a/nerve.yaml b/nerve.yaml index c9dbd48..65df5c2 100644 --- a/nerve.yaml +++ b/nerve.yaml @@ -32,6 +32,9 @@ workflows: hello-world: concurrency: 1 overflow: drop + gitea-issue-solver: + concurrency: 1 + overflow: drop reflexes: - kind: sense diff --git a/workflows/gitea-issue-solver/index.ts b/workflows/gitea-issue-solver/index.ts new file mode 100644 index 0000000..df633f8 --- /dev/null +++ b/workflows/gitea-issue-solver/index.ts @@ -0,0 +1,1069 @@ +import type { + ModeratorContext, + RoleResult, + StartStep, + WorkflowDefinition, + WorkflowMessage, +} from "@uncaged/nerve-core"; +import { END } from "@uncaged/nerve-core"; +import type { SpawnError } from "@uncaged/nerve-workflow-utils"; +import { cursorAgent, llmExtract, nerveAgentContext, readNerveYaml, spawnSafe } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; + +const NERVE_ROOT = "/home/azureuser/.uncaged-nerve"; +const ISSUE_READER_MAX_ATTEMPTS = 3; +const TESTER_MAX_ATTEMPTS = 3; +const PUBLISHER_MAX_ATTEMPTS = 3; + +type IssueData = { + id: number; + number: number; + title: string; + body: string; + state: string; + labels: string[]; + comments: Array<{ + author: string; + body: string; + createdAt: string; + }>; + url: string; +}; + +type TestCommandResult = { + command: string; + ok: boolean; + stdoutPreview: string; + stderrPreview: string; +}; + +type WorkflowMeta = { + intake: { + issueUrl: string; + host: string | null; + owner: string | null; + repo: string | null; + issueNumber: number | null; + valid: boolean; + failureKind: "none" | "invalid_input"; + failureReason: string | null; + }; + "issue-reader": { + issue: IssueData | null; + fetchOk: boolean; + transientError: boolean; + attempt: number; + failureKind: "none" | "transient" | "permanent"; + failureReason: string | null; + }; + planner: { + plan: string | null; + targetFiles: string[] | null; + testCommands: string[] | null; + riskNotes: string[] | null; + planningOk: boolean; + failureKind: "none" | "planning_failed"; + failureReason: string | null; + }; + implementer: { + branchName: string | null; + changedFiles: string[] | null; + implementationOk: boolean; + attempt: number; + failureKind: "none" | "branch_failed" | "agent_failed" | "no_diff"; + failureReason: string | null; + implementationLog: string | null; + }; + tester: { + passed: boolean; + attempt: number; + failureReason: string | null; + testCommandResults: TestCommandResult[] | null; + }; + "pr-publisher": { + prUrl: string | null; + prNumber: number | null; + linkedIssue: string | null; + published: boolean; + attempt: number; + failureKind: "none" | "auth_or_network" | "permanent"; + failureReason: string | null; + }; +}; + +type Provider = { + baseUrl: string; + apiKey: string; + model: string; +}; + +const issueSchema = z.object({ + id: z.number().int().default(0), + number: z.number().int().default(0), + title: z.string().default(""), + body: z.string().default(""), + state: z.string().default("unknown"), + labels: z.array(z.string().default("")).default([]), + comments: z + .array( + z.object({ + author: z.string().default("unknown"), + body: z.string().default(""), + createdAt: z.string().default(""), + }), + ) + .default([]), + url: z.string().default(""), +}); + +const planSchema = z.object({ + targetFiles: z.array(z.string().default("")).default([]), + testCommands: z.array(z.string().default("")).default([]), + riskNotes: z.array(z.string().default("")).default([]), +}); + +function lastMetaForRole(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; +} + +function formatSpawnFailure(error: SpawnError): string { + if (error.kind === "spawn_failed") { + return `spawn_failed: ${error.message}`; + } + if (error.kind === "timeout") { + return `timeout: stdout=${error.stdout.slice(0, 200)} stderr=${error.stderr.slice(0, 200)}`; + } + return `non_zero_exit(${error.exitCode}): stderr=${error.stderr.slice(0, 400)}`; +} + +function errorText(error: SpawnError): string { + if (error.kind === "spawn_failed") { + return error.message.toLowerCase(); + } + if (error.kind === "timeout") { + return `${error.stdout} ${error.stderr}`.toLowerCase(); + } + return `${error.stdout} ${error.stderr}`.toLowerCase(); +} + +function classifyIssueReaderFailure(error: SpawnError): { transientError: boolean; failureKind: "transient" | "permanent" } { + if (error.kind === "timeout" || error.kind === "spawn_failed") { + return { transientError: true, failureKind: "transient" }; + } + const text = errorText(error); + if ( + text.includes("network") || + text.includes("timed out") || + text.includes("timeout") || + text.includes("connection reset") || + text.includes("connection refused") || + text.includes("429") || + text.includes("500") || + text.includes("502") || + text.includes("503") || + text.includes("504") + ) { + return { transientError: true, failureKind: "transient" }; + } + return { transientError: false, failureKind: "permanent" }; +} + +function classifyPublisherFailure(error: SpawnError): "auth_or_network" | "permanent" { + if (error.kind === "timeout" || error.kind === "spawn_failed") { + return "auth_or_network"; + } + const text = errorText(error); + if ( + text.includes("401") || + text.includes("403") || + text.includes("auth") || + text.includes("token") || + text.includes("permission") || + text.includes("network") || + text.includes("connection") || + text.includes("timeout") + ) { + return "auth_or_network"; + } + return "permanent"; +} + +function parseIssueUrl(raw: string): WorkflowMeta["intake"] { + const issueUrl = raw.trim(); + const match = issueUrl.match(/^https?:\/\/([^/\s]+)\/([^/\s]+)\/([^/\s]+)\/issues\/(\d+)(?:[/?#].*)?$/i); + if (match === null) { + return { + issueUrl, + host: null, + owner: null, + repo: null, + issueNumber: null, + valid: false, + failureKind: "invalid_input", + failureReason: `invalid issue URL: ${issueUrl}`, + }; + } + + const issueNumber = Number(match[4]); + if (!Number.isInteger(issueNumber) || issueNumber <= 0) { + return { + issueUrl, + host: null, + owner: null, + repo: null, + issueNumber: null, + valid: false, + failureKind: "invalid_input", + failureReason: `invalid issue number: ${match[4]}`, + }; + } + + return { + issueUrl, + host: match[1], + owner: match[2], + repo: match[3], + issueNumber, + valid: true, + failureKind: "none", + failureReason: null, + }; +} + +function slugify(input: string): string { + const normalized = input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized.length > 0 ? normalized.slice(0, 40) : "update"; +} + +function readNerveConfigText(): string { + const result = readNerveYaml({ nerveRoot: NERVE_ROOT }); + return result.ok ? result.value : "# nerve.yaml unavailable"; +} + +async function cfgGet(key: string): Promise { + const result = await spawnSafe("cfg", ["get", key], { cwd: NERVE_ROOT, env: null, timeoutMs: 10_000 }); + if (!result.ok) { + return null; + } + const value = result.value.stdout.trim(); + return value.length > 0 ? value : null; +} + +async function resolveDashScopeProvider(): Promise { + 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 toIssueFromJson(raw: unknown): IssueData | null { + if (typeof raw !== "object" || raw === null) { + return null; + } + const obj = raw as Record; + const labels = (Array.isArray(obj.labels) ? obj.labels : []) + .map((label) => { + if (typeof label === "string") { + return label; + } + if (typeof label === "object" && label !== null && typeof (label as Record).name === "string") { + return String((label as Record).name); + } + return ""; + }) + .filter((label) => label.length > 0); + const comments = (Array.isArray(obj.comments) ? obj.comments : []).map((comment) => { + const item = (comment ?? {}) as Record; + const userObj = (item.user ?? {}) as Record; + return { + author: + typeof item.author === "string" + ? item.author + : typeof userObj.login === "string" + ? userObj.login + : "unknown", + body: typeof item.body === "string" ? item.body : "", + createdAt: + typeof item.created_at === "string" + ? item.created_at + : typeof item.createdAt === "string" + ? item.createdAt + : "", + }; + }); + + const parsed = issueSchema.parse({ + id: Number(obj.id ?? 0), + number: Number(obj.number ?? 0), + title: typeof obj.title === "string" ? obj.title : "", + body: typeof obj.body === "string" ? obj.body : "", + state: typeof obj.state === "string" ? obj.state : "unknown", + labels, + comments, + url: typeof obj.url === "string" ? obj.url : "", + }); + return parsed.number > 0 ? parsed : null; +} + +async function parseIssueFromText(text: string, provider: Provider | null): Promise { + if (provider === null) { + return null; + } + const extracted = await llmExtract({ text, schema: issueSchema, provider }); + if (!extracted.ok) { + return null; + } + return extracted.value.number > 0 ? extracted.value : null; +} + +async function runWithRetries( + run: () => Promise< + | { ok: true; stdout: string; stderr: string } + | { ok: false; error: SpawnError; lastStdout: string; lastStderr: string } + >, + maxAttempts: number, +): Promise< + | { ok: true; stdout: string; stderr: string; attemptsUsed: number } + | { ok: false; error: SpawnError; attemptsUsed: number; lastStdout: string; lastStderr: string } +> { + let attemptsUsed = 0; + let lastError: SpawnError | null = null; + let lastStdout = ""; + let lastStderr = ""; + while (attemptsUsed < maxAttempts) { + attemptsUsed += 1; + const current = await run(); + if (current.ok) { + return { ok: true, stdout: current.stdout, stderr: current.stderr, attemptsUsed }; + } + lastError = current.error; + lastStdout = current.lastStdout; + lastStderr = current.lastStderr; + if (attemptsUsed < maxAttempts) { + const backoffMs = Math.min(1000 * 2 ** (attemptsUsed - 1), 4000); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } + } + if (lastError === null) { + return { + ok: false, + error: { kind: "spawn_failed", message: "unknown retry failure" }, + attemptsUsed, + lastStdout, + lastStderr, + }; + } + return { ok: false, error: lastError, attemptsUsed, lastStdout, lastStderr }; +} + +function extractPrInfo(text: string): { prUrl: string | null; prNumber: number | null } { + const url = text.match(/https?:\/\/\S+/)?.[0] ?? null; + const num = text.match(/(?:pulls|pull|pr)\/(\d+)/i)?.[1] ?? text.match(/#(\d+)/)?.[1] ?? null; + return { prUrl: url, prNumber: num === null ? null : Number(num) }; +} + +async function runTestCommand(command: string): Promise<{ + ok: boolean; + stdoutPreview: string; + stderrPreview: string; + reason: string | null; +}> { + const result = await spawnSafe("bash", ["-lc", command], { + cwd: NERVE_ROOT, + env: null, + timeoutMs: 300_000, + }); + if (result.ok) { + return { + ok: true, + stdoutPreview: result.value.stdout.slice(0, 600), + stderrPreview: result.value.stderr.slice(0, 400), + reason: null, + }; + } + return { + ok: false, + stdoutPreview: (result.error.kind === "spawn_failed" ? "" : result.error.stdout).slice(0, 600), + stderrPreview: (result.error.kind === "spawn_failed" ? result.error.message : result.error.stderr).slice(0, 400), + reason: formatSpawnFailure(result.error), + }; +} + +async function intake(start: StartStep, _messages: WorkflowMessage[]): Promise> { + const parsed = parseIssueUrl(start.content); + if (!parsed.valid) { + return { content: parsed.failureReason ?? "invalid issue URL", meta: parsed }; + } + return { + content: `parsed issue URL: ${parsed.owner}/${parsed.repo}#${parsed.issueNumber} @ ${parsed.host}`, + meta: parsed, + }; +} + +async function issueReader( + _start: StartStep, + messages: WorkflowMessage[], +): Promise> { + const intakeMeta = lastMetaForRole(messages, "intake"); + const attempt = messages.filter((message) => message.role === "issue-reader").length + 1; + + if ( + intakeMeta === null || + !intakeMeta.valid || + intakeMeta.owner === null || + intakeMeta.repo === null || + intakeMeta.issueNumber === null + ) { + return { + content: "issue-reader cannot continue: missing valid intake meta", + meta: { + issue: null, + fetchOk: false, + transientError: false, + attempt, + failureKind: "permanent", + failureReason: "missing valid intake meta", + }, + }; + } + + const repoSpec = `${intakeMeta.owner}/${intakeMeta.repo}`; + const jsonRun = await runWithRetries(async () => { + const run = await spawnSafe( + "tea", + [ + "issue", + "show", + String(intakeMeta.issueNumber), + "--repo", + repoSpec, + "--comments", + "--json", + "id,number,title,body,state,labels,comments,url", + ], + { + cwd: NERVE_ROOT, + env: null, + timeoutMs: 60_000, + }, + ); + if (run.ok) { + return { ok: true, stdout: run.value.stdout, stderr: run.value.stderr }; + } + return { + ok: false, + error: run.error, + lastStdout: run.error.kind === "spawn_failed" ? "" : run.error.stdout, + lastStderr: run.error.kind === "spawn_failed" ? run.error.message : run.error.stderr, + }; + }, ISSUE_READER_MAX_ATTEMPTS); + + if (jsonRun.ok) { + try { + const issue = toIssueFromJson(JSON.parse(jsonRun.stdout) as unknown); + if (issue !== null) { + return { + content: `issue fetched: #${issue.number} ${issue.title}`, + meta: { + issue, + fetchOk: true, + transientError: false, + attempt, + failureKind: "none", + failureReason: null, + }, + }; + } + } catch { + // fallback to text parsing + } + } + + const textRun = await runWithRetries(async () => { + const run = await spawnSafe( + "tea", + ["issue", "show", String(intakeMeta.issueNumber), "--repo", repoSpec, "--comments"], + { + cwd: NERVE_ROOT, + env: null, + timeoutMs: 60_000, + }, + ); + if (run.ok) { + return { ok: true, stdout: run.value.stdout, stderr: run.value.stderr }; + } + return { + ok: false, + error: run.error, + lastStdout: run.error.kind === "spawn_failed" ? "" : run.error.stdout, + lastStderr: run.error.kind === "spawn_failed" ? run.error.message : run.error.stderr, + }; + }, ISSUE_READER_MAX_ATTEMPTS); + + if (textRun.ok) { + const provider = await resolveDashScopeProvider(); + const issue = await parseIssueFromText(textRun.stdout, provider); + if (issue !== null) { + return { + content: `issue fetched (text+extract): #${issue.number} ${issue.title}`, + meta: { + issue, + fetchOk: true, + transientError: false, + attempt, + failureKind: "none", + failureReason: null, + }, + }; + } + return { + content: "tea issue output received, but could not parse structured fields.", + meta: { + issue: null, + fetchOk: false, + transientError: false, + attempt, + failureKind: "permanent", + failureReason: "unable to parse tea issue output", + }, + }; + } + + const classified = classifyIssueReaderFailure(textRun.error); + return { + content: `issue read failed after retries: ${formatSpawnFailure(textRun.error)}`, + meta: { + issue: null, + fetchOk: false, + transientError: classified.transientError, + attempt, + failureKind: classified.failureKind, + failureReason: formatSpawnFailure(textRun.error), + }, + }; +} + +async function planner(_start: StartStep, messages: WorkflowMessage[]): Promise> { + const issueMeta = lastMetaForRole(messages, "issue-reader"); + if (issueMeta === null || !issueMeta.fetchOk || issueMeta.issue === null) { + return { + content: "planner cannot continue: issue-reader has no issue data", + meta: { + plan: null, + targetFiles: null, + testCommands: null, + riskNotes: null, + planningOk: false, + failureKind: "planning_failed", + failureReason: "missing issue data", + }, + }; + } + + const prompt = `You are planning a fix for a Gitea issue in this repository. + +${nerveAgentContext} + +Issue URL: ${issueMeta.issue.url} +Issue title: ${issueMeta.issue.title} +Issue body: +${issueMeta.issue.body} + +Issue comments: +${issueMeta.issue.comments.map((c) => `- ${c.author} (${c.createdAt}): ${c.body}`).join("\n") || "(none)"} + +Current nerve.yaml: +\`\`\`yaml +${readNerveConfigText()} +\`\`\` + +Output implementation-ready markdown with sections: +1) Problem understanding +2) Change strategy +3) Test strategy (commands) +4) Risks`; + + const result = await cursorAgent({ + prompt, + mode: "ask", + cwd: NERVE_ROOT, + env: null, + timeoutMs: null, + }); + if (!result.ok) { + return { + content: `planner failed: ${formatSpawnFailure(result.error)}`, + meta: { + plan: null, + targetFiles: null, + testCommands: null, + riskNotes: null, + planningOk: false, + failureKind: "planning_failed", + failureReason: formatSpawnFailure(result.error), + }, + }; + } + + const plan = result.value; + const provider = await resolveDashScopeProvider(); + if (provider === null) { + return { + content: plan, + meta: { + plan, + targetFiles: null, + testCommands: null, + riskNotes: null, + planningOk: true, + failureKind: "none", + failureReason: null, + }, + }; + } + + const structured = await llmExtract({ text: plan, schema: planSchema, provider }); + if (!structured.ok) { + return { + content: `${plan}\n\n[llmExtract error] ${JSON.stringify(structured.error)}`, + meta: { + plan, + targetFiles: null, + testCommands: null, + riskNotes: null, + planningOk: true, + failureKind: "none", + failureReason: null, + }, + }; + } + + return { + content: plan, + meta: { + plan, + targetFiles: structured.value.targetFiles, + testCommands: structured.value.testCommands, + riskNotes: structured.value.riskNotes, + planningOk: true, + failureKind: "none", + failureReason: null, + }, + }; +} + +async function implementer( + _start: StartStep, + messages: WorkflowMessage[], +): Promise> { + const plannerMeta = lastMetaForRole(messages, "planner"); + const intakeMeta = lastMetaForRole(messages, "intake"); + const issueMeta = lastMetaForRole(messages, "issue-reader"); + const attempt = messages.filter((message) => message.role === "implementer").length + 1; + + if ( + plannerMeta === null || + !plannerMeta.planningOk || + plannerMeta.plan === null || + intakeMeta === null || + intakeMeta.issueNumber === null + ) { + return { + content: "implementer cannot continue: missing planner or intake context", + meta: { + branchName: null, + changedFiles: null, + implementationOk: false, + attempt, + failureKind: "agent_failed", + failureReason: "missing planner/intake context", + implementationLog: null, + }, + }; + } + + const slugSource = issueMeta?.issue?.title ?? plannerMeta.plan.split("\n")[0] ?? "fix"; + const branchName = `fix/issue-${intakeMeta.issueNumber}-${slugify(slugSource)}`; + + const existsResult = await spawnSafe("git", ["rev-parse", "--verify", `refs/heads/${branchName}`], { + cwd: NERVE_ROOT, + env: null, + timeoutMs: 10_000, + }); + const checkoutResult = existsResult.ok + ? await spawnSafe("git", ["checkout", branchName], { cwd: NERVE_ROOT, env: null, timeoutMs: 20_000 }) + : await spawnSafe("git", ["checkout", "-b", branchName], { cwd: NERVE_ROOT, env: null, timeoutMs: 20_000 }); + + if (!checkoutResult.ok) { + return { + content: `branch setup failed: ${formatSpawnFailure(checkoutResult.error)}`, + meta: { + branchName, + changedFiles: null, + implementationOk: false, + attempt, + failureKind: "branch_failed", + failureReason: formatSpawnFailure(checkoutResult.error), + implementationLog: null, + }, + }; + } + + const prompt = `Implement the planned fix in this repository. + +Issue #${intakeMeta.issueNumber} +Plan: +${plannerMeta.plan} + +Target files: +${plannerMeta.targetFiles?.join("\n") ?? "(not specified)"} + +Test commands: +${plannerMeta.testCommands?.join("\n") ?? "(not specified)"} + +Apply the code changes now with minimal, focused edits.`; + + const agentResult = await cursorAgent({ + prompt, + mode: "default", + cwd: NERVE_ROOT, + env: null, + timeoutMs: null, + }); + if (!agentResult.ok) { + return { + content: `implementer agent failed: ${formatSpawnFailure(agentResult.error)}`, + meta: { + branchName, + changedFiles: null, + implementationOk: false, + attempt, + failureKind: "agent_failed", + failureReason: formatSpawnFailure(agentResult.error), + implementationLog: null, + }, + }; + } + + const diffResult = await spawnSafe("git", ["diff", "--name-only"], { + cwd: NERVE_ROOT, + env: null, + timeoutMs: 15_000, + }); + if (!diffResult.ok) { + return { + content: `implementation finished but diff check failed: ${formatSpawnFailure(diffResult.error)}`, + meta: { + branchName, + changedFiles: null, + implementationOk: false, + attempt, + failureKind: "no_diff", + failureReason: formatSpawnFailure(diffResult.error), + implementationLog: agentResult.value, + }, + }; + } + + const changedFiles = diffResult.value.stdout + .split("\n") + .map((file) => file.trim()) + .filter((file) => file.length > 0); + if (changedFiles.length === 0) { + return { + content: "implementer made no local diff", + meta: { + branchName, + changedFiles: [], + implementationOk: false, + attempt, + failureKind: "no_diff", + failureReason: "git diff --name-only is empty", + implementationLog: agentResult.value, + }, + }; + } + + return { + content: `branch ready: ${branchName}\nchanged files:\n${changedFiles.join("\n")}`, + meta: { + branchName, + changedFiles, + implementationOk: true, + attempt, + failureKind: "none", + failureReason: null, + implementationLog: agentResult.value, + }, + }; +} + +async function tester(_start: StartStep, messages: WorkflowMessage[]): Promise> { + const plannerMeta = lastMetaForRole(messages, "planner"); + const implementerMeta = lastMetaForRole(messages, "implementer"); + const attempt = messages.filter((message) => message.role === "tester").length + 1; + + if (implementerMeta === null || !implementerMeta.implementationOk || implementerMeta.branchName === null) { + return { + content: "tester cannot continue: no successful implementer output", + meta: { + passed: false, + attempt, + failureReason: "no successful implementer output", + testCommandResults: null, + }, + }; + } + + const commands = + plannerMeta?.testCommands !== null && plannerMeta?.testCommands !== undefined && plannerMeta.testCommands.length > 0 + ? plannerMeta.testCommands + : ["pnpm test"]; + + const testCommandResults: TestCommandResult[] = []; + for (const command of commands) { + const run = await runTestCommand(command); + testCommandResults.push({ + command, + ok: run.ok, + stdoutPreview: run.stdoutPreview, + stderrPreview: run.stderrPreview, + }); + if (!run.ok) { + return { + content: `test failed: ${command}\n${run.reason ?? "unknown error"}`, + meta: { + passed: false, + attempt, + failureReason: `command failed: ${command} (${run.reason ?? "unknown error"})`, + testCommandResults, + }, + }; + } + } + + return { + content: `all tests passed on branch ${implementerMeta.branchName}`, + meta: { + passed: true, + attempt, + failureReason: null, + testCommandResults, + }, + }; +} + +async function prPublisher( + _start: StartStep, + messages: WorkflowMessage[], +): Promise> { + const attempt = messages.filter((message) => message.role === "pr-publisher").length + 1; + const intakeMeta = lastMetaForRole(messages, "intake"); + const issueMeta = lastMetaForRole(messages, "issue-reader"); + const plannerMeta = lastMetaForRole(messages, "planner"); + const implementerMeta = lastMetaForRole(messages, "implementer"); + const testerMeta = lastMetaForRole(messages, "tester"); + + if (testerMeta === null || !testerMeta.passed) { + return { + content: "skip PR publishing: tester.passed is not true", + meta: { + prUrl: null, + prNumber: null, + linkedIssue: intakeMeta?.issueUrl ?? null, + published: false, + attempt, + failureKind: "permanent", + failureReason: "tester did not pass", + }, + }; + } + if ( + intakeMeta === null || + intakeMeta.owner === null || + intakeMeta.repo === null || + implementerMeta === null || + implementerMeta.branchName === null + ) { + return { + content: "pr-publisher cannot continue: missing required context", + meta: { + prUrl: null, + prNumber: null, + linkedIssue: intakeMeta?.issueUrl ?? null, + published: false, + attempt, + failureKind: "permanent", + failureReason: "missing context", + }, + }; + } + + const pushRun = await runWithRetries(async () => { + const run = await spawnSafe("git", ["push", "-u", "origin", implementerMeta.branchName as string], { + cwd: NERVE_ROOT, + env: null, + timeoutMs: 180_000, + }); + if (run.ok) { + return { ok: true, stdout: run.value.stdout, stderr: run.value.stderr }; + } + return { + ok: false, + error: run.error, + lastStdout: run.error.kind === "spawn_failed" ? "" : run.error.stdout, + lastStderr: run.error.kind === "spawn_failed" ? run.error.message : run.error.stderr, + }; + }, PUBLISHER_MAX_ATTEMPTS); + + if (!pushRun.ok) { + const failureKind = classifyPublisherFailure(pushRun.error); + return { + content: `failed to push branch before PR: ${formatSpawnFailure(pushRun.error)}`, + meta: { + prUrl: null, + prNumber: null, + linkedIssue: intakeMeta.issueUrl, + published: false, + attempt, + failureKind, + failureReason: formatSpawnFailure(pushRun.error), + }, + }; + } + + const shortTitle = (issueMeta?.issue?.title ?? "issue fix").slice(0, 72); + const title = `fix: ${shortTitle}`; + const body = [ + `Issue: ${intakeMeta.issueUrl}`, + "", + "## Summary", + ...(implementerMeta.changedFiles ?? []).map((file) => `- updated \`${file}\``), + "", + "## Plan", + plannerMeta?.plan ?? "(no planner output)", + "", + "## Test Results", + testerMeta.testCommandResults?.map((r) => `- ${r.ok ? "PASS" : "FAIL"} \`${r.command}\``).join("\n") ?? + "- no test logs", + ].join("\n"); + const repoSpec = `${intakeMeta.owner}/${intakeMeta.repo}`; + + const prRun = await runWithRetries(async () => { + const run = await spawnSafe( + "tea", + ["pr", "create", "--repo", repoSpec, "--title", title, "--body", body, "--head", implementerMeta.branchName as string], + { + cwd: NERVE_ROOT, + env: null, + timeoutMs: 120_000, + }, + ); + if (run.ok) { + return { ok: true, stdout: run.value.stdout, stderr: run.value.stderr }; + } + return { + ok: false, + error: run.error, + lastStdout: run.error.kind === "spawn_failed" ? "" : run.error.stdout, + lastStderr: run.error.kind === "spawn_failed" ? run.error.message : run.error.stderr, + }; + }, PUBLISHER_MAX_ATTEMPTS); + + if (!prRun.ok) { + const failureKind = classifyPublisherFailure(prRun.error); + return { + content: `PR creation failed: ${formatSpawnFailure(prRun.error)}`, + meta: { + prUrl: null, + prNumber: null, + linkedIssue: intakeMeta.issueUrl, + published: false, + attempt, + failureKind, + failureReason: formatSpawnFailure(prRun.error), + }, + }; + } + + const info = extractPrInfo(`${prRun.stdout}\n${prRun.stderr}`); + return { + content: `PR created: ${title}\n${prRun.stdout}`, + meta: { + prUrl: info.prUrl, + prNumber: info.prNumber, + linkedIssue: intakeMeta.issueUrl, + published: true, + attempt, + failureKind: "none", + failureReason: null, + }, + }; +} + +const workflow: WorkflowDefinition = { + name: "gitea-issue-solver", + roles: { + intake, + "issue-reader": issueReader, + planner, + implementer, + tester, + "pr-publisher": prPublisher, + }, + moderator(context: ModeratorContext) { + if (context.steps.length === 0) { + return "intake"; + } + + const last = context.steps[context.steps.length - 1]; + if (last.role === "intake") { + const meta = last.meta as WorkflowMeta["intake"]; + return meta.valid ? "issue-reader" : END; + } + + if (last.role === "issue-reader") { + const meta = last.meta as WorkflowMeta["issue-reader"]; + if (meta.fetchOk) { + return "planner"; + } + return END; + } + + if (last.role === "planner") { + const meta = last.meta as WorkflowMeta["planner"]; + return meta.planningOk ? "implementer" : END; + } + + if (last.role === "implementer") { + return "tester"; + } + + if (last.role === "tester") { + const meta = last.meta as WorkflowMeta["tester"]; + if (meta.passed) { + return "pr-publisher"; + } + return meta.attempt < TESTER_MAX_ATTEMPTS ? "implementer" : END; + } + + if (last.role === "pr-publisher") { + return END; + } + + return END; + }, +}; + +export default workflow; diff --git a/workflows/gitea-issue-solver/package.json b/workflows/gitea-issue-solver/package.json new file mode 100644 index 0000000..c0637d3 --- /dev/null +++ b/workflows/gitea-issue-solver/package.json @@ -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" + } + } +} diff --git a/workflows/gitea-issue-solver/pnpm-lock.yaml b/workflows/gitea-issue-solver/pnpm-lock.yaml new file mode 100644 index 0000000..237146e --- /dev/null +++ b/workflows/gitea-issue-solver/pnpm-lock.yaml @@ -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: {} diff --git a/workflows/gitea-issue-solver/tsconfig.json b/workflows/gitea-issue-solver/tsconfig.json new file mode 100644 index 0000000..fc00159 --- /dev/null +++ b/workflows/gitea-issue-solver/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/workflows/sense-generator/index.ts b/workflows/sense-generator/index.ts index b31976e..d0351fc 100644 --- a/workflows/sense-generator/index.ts +++ b/workflows/sense-generator/index.ts @@ -5,25 +5,28 @@ import type { WorkflowMessage, } 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 { - createCursorRole, - nerveAgentContext, - readNerveYaml, - spawnSafe, -} from "@uncaged/nerve-workflow-utils"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { z } from "zod"; +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + const HOME = process.env.HOME ?? "/home/azureuser"; const NERVE_ROOT = join(HOME, ".uncaged-nerve"); const SENSES_DIR = join(NERVE_ROOT, "senses"); -const AGENT_TIMEOUT_MS = 3_600_000; -function getNerveYaml(): string { - const result = readNerveYaml({ nerveRoot: NERVE_ROOT }); - return result.ok ? result.value : "# nerve.yaml unavailable"; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +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 { @@ -32,9 +35,7 @@ async function cfgGet(key: string): Promise { env: null, timeoutMs: 10_000, }); - if (!result.ok) { - return null; - } + if (!result.ok) return null; return result.value.stdout.trim() || null; } @@ -45,107 +46,19 @@ async function resolveDashScopeProvider(): Promise<{ } | 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; - } + 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; +function getNerveYaml(): string { + try { + 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 { const examples: string[] = []; for (const name of ["cpu-usage", "linux-system-health"]) { @@ -172,18 +85,78 @@ function buildSenseExamples(): string { return examples.join("\n\n---\n\n"); } -function getSenseNameFromThread(messages: WorkflowMessage[]): string { - const p = messages.find((m) => m.role === "planner"); - if (p === undefined || typeof p.meta !== "object" || p.meta === null) { - return ""; +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}`, + }; } - return String((p.meta as { senseName: string }).senseName); + 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`, + }; + } + + 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: `Trigger failed: ${triggerRun.err}` }; + } + logParts.push("=== nerve sense trigger ===\n" + triggerRun.out); + + let lastQuery = ""; + 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 { + 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" + : "Timed out waiting for successful sense query", + }; } -function getPlanFromThread(messages: WorkflowMessage[]): string { - const p = messages.find((m) => m.role === "planner"); - return p !== undefined ? p.content : ""; -} +// --------------------------------------------------------------------------- +// Meta β€” routing-only signals for the moderator +// --------------------------------------------------------------------------- type SenseMeta = { planner: { senseName: string }; @@ -191,185 +164,112 @@ type SenseMeta = { tester: { passed: boolean; attempt: number }; }; -const plannerMetaSchema = z - .object({ - senseName: z - .string() - .describe("kebab-case sense name from the plan, e.g. 'disk-usage'"), - }) - .describe("Extract the kebab-case sense name from the plan text"); +// --------------------------------------------------------------------------- +// Bake static context (read once at module load, not per-call) +// --------------------------------------------------------------------------- -const coderMetaSchema = z - .object({ - filesCreated: z - .boolean() - .describe("true if index.js, schema.ts, migrations/0001_init.sql exist and nerve.yaml was updated"), - }) - .describe("Whether the agent completed all file work for the sense"); +const senseExamples = buildSenseExamples(); +const nerveYaml = getNerveYaml(); -async function runPlanner( - start: StartStep, - _messages: WorkflowMessage[], -): Promise> { - const userInput = start.content; +// --------------------------------------------------------------------------- +// Roles +// --------------------------------------------------------------------------- + +async function buildPlannerRole() { const provider = await resolveDashScopeProvider(); if (provider === null) { - return { - content: - "Cannot run planner: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or configure via `cfg get`), " + - "and optionally DASHSCOPE_MODEL.", - meta: { senseName: "" }, - }; + throw new Error("Cannot create planner: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL"); } - - const role = createCursorRole({ + return createCursorRole({ cwd: NERVE_ROOT, mode: "ask", - timeoutMs: AGENT_TIMEOUT_MS, - prompt: async (threadId) => { - return `You are planning a new Nerve sense. + prompt: async (threadId) => + `You are planning a new Nerve sense. -${nerveAgentContext} +Read the workflow thread for the user's request: \`nerve thread ${threadId}\` -**Context:** Read this workflow run for background before you plan. From a shell in \`${NERVE_ROOT}\`, run: -\`nerve thread show ${threadId} --budget 50000\` -Use the thread transcript (prior user messages and rounds) when deciding the sense. - -User request: ${userInput} -Pick a good kebab-case name for this sense. - -Your job is to produce a PLAN (not code) for this sense. Output a structured plan in markdown with these sections: +Pick a good kebab-case name for this sense. Produce a PLAN (not code) in markdown: ## Sense Design +### Name β€” kebab-case +### Fields β€” name, type (integer/real/text), description +### Compute Logic β€” step-by-step, specific Node.js APIs or shell commands +### Trigger Config β€” group, interval, throttle, timeout -### Name -(decide a kebab-case name) - -### 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()} +Reference senses: +${senseExamples} Current nerve.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: { + provider, + schema: z.object({ + senseName: z.string().describe("kebab-case sense name from the plan"), + }), }, - extract: { provider, schema: plannerMetaSchema }, }); - - try { - return await role(start, _messages); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - return { content: message, meta: { senseName: "" } }; - } } -async function runCoder( - _start: StartStep, - messages: WorkflowMessage[], -): Promise> { - const plan = getPlanFromThread(messages); - const senseName = getSenseNameFromThread(messages); +async function buildCoderRole() { const provider = await resolveDashScopeProvider(); if (provider === null) { - return { - content: - "Cannot run coder: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or configure via `cfg get`), " + - "and optionally DASHSCOPE_MODEL.", - meta: { filesCreated: false }, - }; + throw new Error("Cannot create coder: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL"); } - - const role = createCursorRole({ + return createCursorRole({ cwd: NERVE_ROOT, mode: "default", - timeoutMs: AGENT_TIMEOUT_MS, - prompt: async (threadId) => { - return `You are implementing a new Nerve sense called "${senseName}" in the directory ${SENSES_DIR}/${senseName}/. + prompt: async (threadId) => + `Read the workflow thread for the planner's sense design: \`nerve thread ${threadId}\` -**Context:** Read this workflow run for background before you edit files. From a shell in \`${NERVE_ROOT}\`, run: -\`nerve thread show ${threadId} --budget 50000\` +Implement the sense. Create exactly: +1. The sense directory under ${SENSES_DIR}// +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 (from the planner step): - -${plan} - -You need to create exactly 3 files: -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. End with a clear statement of whether all files and updates were created successfully, or what is still missing.`; +Follow the patterns from existing senses. Create all files now.`, + extract: { + provider, + schema: z.object({ + filesCreated: z.boolean().describe("true if the sense files were created"), + }), }, - extract: { provider, schema: coderMetaSchema }, }); - - try { - return await role(_start, messages); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - return { content: message, meta: { filesCreated: false } }; - } } -async function runTester( +// Tester: pure CLI logic β€” stays hand-written +async function tester( _start: StartStep, messages: WorkflowMessage[], ): Promise> { - const senseName = getSenseNameFromThread(messages); - 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")), - }; - const attempt = messages.filter((m) => m.role === "tester").length + 1; - if (!senseName) { + // Get senseName from planner meta + const plannerStep = messages.find((m) => m.role === "planner"); + const senseName = plannerStep + ? (plannerStep.meta as SenseMeta["planner"]).senseName + : ""; + + if (senseName.length === 0) { return { - content: "FAIL β€” no senseName from planner meta", + content: "FAIL β€” no senseName from planner", meta: { passed: false, attempt }, }; } - const missing = Object.entries(files).filter(([, v]) => !v).map(([k]) => k); + // 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 (missing.length > 0) { return { content: `FAIL β€” missing files: ${missing.join(", ")}`, @@ -377,60 +277,42 @@ async function runTester( }; } + // Smoke test const smoke = await runSenseSmokeTest(senseName); + return { + content: `${smoke.ok ? "PASS" : "FAIL"} β€” ${smoke.reason}`, + meta: { passed: smoke.ok, attempt }, + }; +} - if (smoke.ok) { - return { - content: `PASS β€” ${smoke.reason}`, - meta: { passed: true, attempt }, - }; - } +// --------------------------------------------------------------------------- +// Workflow definition +// --------------------------------------------------------------------------- + +async function buildWorkflow(): Promise> { + const plannerRole = await buildPlannerRole(); + const coderRole = await buildCoderRole(); return { - content: `FAIL β€” ${smoke.reason}\n\n--- smoke log ---\n${smoke.log}`, - meta: { - passed: false, - attempt, + name: "sense-generator", + roles: { + planner: plannerRole, + coder: coderRole, + tester, + }, + moderator(context) { + if (context.steps.length === 0) return "planner"; + const last = context.steps[context.steps.length - 1]; + if (last.role === "planner") return "coder"; + if (last.role === "coder") return "tester"; + if (last.role === "tester") { + if (last.meta.passed) return END; + return last.meta.attempt < 3 ? "coder" : END; + } + return END; }, }; } -const workflow: WorkflowDefinition = { - name: "sense-generator", - - roles: { - planner: runPlanner, - coder: runCoder, - tester: runTester, - }, - - moderator(context) { - if (context.steps.length === 0) { - return "planner"; - } - - const signal = context.steps[context.steps.length - 1]; - 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; - }, -}; - +const workflow = await buildWorkflow(); export default workflow; diff --git a/workflows/workflow-generator/index.ts b/workflows/workflow-generator/index.ts index a515423..5d1dd2f 100644 --- a/workflows/workflow-generator/index.ts +++ b/workflows/workflow-generator/index.ts @@ -77,11 +77,11 @@ const plannerExtractSchema = z.object({ .default("") .describe("kebab-case workflow name under workflows/, e.g. issue-fixer"), roles: z.array(roleSchema).default([]), - flowTransitions: z.string().default(""), - validationLoopsDesign: z.string().default(""), - externalDeps: z.string().default(""), - dataFlow: z.string().default(""), - planMarkdown: z.string().default(""), + flowTransitions: z.preprocess((v) => (Array.isArray(v) ? v.join("\n") : v), z.string().default("")), + validationLoopsDesign: z.preprocess((v) => (Array.isArray(v) ? v.join("\n") : v), z.string().default("")), + externalDeps: z.preprocess((v) => (Array.isArray(v) ? v.join(", ") : v), z.string().default("")), + dataFlow: z.preprocess((v) => (Array.isArray(v) ? v.join("\n") : v), z.string().default("")), + planMarkdown: z.preprocess((v) => (Array.isArray(v) ? v.join("\n") : v), z.string().default("")), }); function getNerveYaml(): string { @@ -778,7 +778,9 @@ Rules: const last = context.steps[context.steps.length - 1]; if (last.role === "planner") { - return last.meta.workflowName.trim().length > 0 ? "coder" : END; + if (last.meta.workflowName.trim().length > 0) return "coder"; + const plannerAttempts = context.steps.filter((s) => s.role === "planner").length; + return plannerAttempts < 3 ? "planner" : END; } if (last.role === "coder") { if (last.meta.lintPassed && last.meta.buildPassed) {