diff --git a/nerve.yaml b/nerve.yaml index 991ecc2..5671ac2 100644 --- a/nerve.yaml +++ b/nerve.yaml @@ -27,13 +27,10 @@ senses: timeout: 15s workflows: - sense-generator: + generate-sense: concurrency: 1 overflow: drop - workflow-generator: - concurrency: 1 - overflow: drop - gitea-issue-solver: + generate-workflow: concurrency: 1 overflow: drop solve-issue: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20934bf..ad325e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,7 +93,7 @@ importers: specifier: ^5.7.0 version: 5.9.3 - workflows/gitea-issue-solver: + workflows/generate-sense: dependencies: '@uncaged/nerve-core': specifier: link:../../../repos/nerve/packages/core @@ -115,7 +115,7 @@ importers: specifier: ^5.7.0 version: 5.9.3 - workflows/sense-generator: + workflows/generate-workflow: dependencies: '@uncaged/nerve-core': specifier: link:../../../repos/nerve/packages/core @@ -159,28 +159,6 @@ importers: specifier: ^5.7.0 version: 5.9.3 - workflows/workflow-generator: - 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 - esbuild: - specifier: ^0.27.0 - version: 0.27.7 - typescript: - specifier: ^5.7.0 - version: 5.9.3 - packages: '@drizzle-team/brocli@0.10.2': diff --git a/workflows/gitea-issue-solver/.gitignore b/workflows/generate-sense/.gitignore similarity index 100% rename from workflows/gitea-issue-solver/.gitignore rename to workflows/generate-sense/.gitignore diff --git a/workflows/sense-generator/build.ts b/workflows/generate-sense/build.ts similarity index 97% rename from workflows/sense-generator/build.ts rename to workflows/generate-sense/build.ts index 55b5917..f6d331f 100644 --- a/workflows/sense-generator/build.ts +++ b/workflows/generate-sense/build.ts @@ -18,7 +18,7 @@ export function buildSenseGenerator({ cwd, }: BuildSenseGeneratorDeps): WorkflowDefinition { return { - name: "sense-generator", + name: "generate-sense", roles: { planner: buildPlannerRole({ provider, cwd }), coder: buildCoderRole({ provider, cwd }), diff --git a/workflows/sense-generator/index.ts b/workflows/generate-sense/index.ts similarity index 100% rename from workflows/sense-generator/index.ts rename to workflows/generate-sense/index.ts diff --git a/workflows/sense-generator/moderator.ts b/workflows/generate-sense/moderator.ts similarity index 100% rename from workflows/sense-generator/moderator.ts rename to workflows/generate-sense/moderator.ts diff --git a/workflows/sense-generator/package.json b/workflows/generate-sense/package.json similarity index 91% rename from workflows/sense-generator/package.json rename to workflows/generate-sense/package.json index 82ea352..c9a64ac 100644 --- a/workflows/sense-generator/package.json +++ b/workflows/generate-sense/package.json @@ -1,5 +1,5 @@ { - "name": "sense-generator-workflow", + "name": "generate-sense-workflow", "version": "0.0.1", "private": true, "type": "module", diff --git a/workflows/sense-generator/roles/coder/index.ts b/workflows/generate-sense/roles/coder/index.ts similarity index 100% rename from workflows/sense-generator/roles/coder/index.ts rename to workflows/generate-sense/roles/coder/index.ts diff --git a/workflows/sense-generator/roles/coder/prompt.ts b/workflows/generate-sense/roles/coder/prompt.ts similarity index 100% rename from workflows/sense-generator/roles/coder/prompt.ts rename to workflows/generate-sense/roles/coder/prompt.ts diff --git a/workflows/sense-generator/roles/committer/index.ts b/workflows/generate-sense/roles/committer/index.ts similarity index 100% rename from workflows/sense-generator/roles/committer/index.ts rename to workflows/generate-sense/roles/committer/index.ts diff --git a/workflows/sense-generator/roles/planner/index.ts b/workflows/generate-sense/roles/planner/index.ts similarity index 100% rename from workflows/sense-generator/roles/planner/index.ts rename to workflows/generate-sense/roles/planner/index.ts diff --git a/workflows/sense-generator/roles/planner/prompt.ts b/workflows/generate-sense/roles/planner/prompt.ts similarity index 100% rename from workflows/sense-generator/roles/planner/prompt.ts rename to workflows/generate-sense/roles/planner/prompt.ts diff --git a/workflows/gitea-issue-solver/roles/reviewer/index.ts b/workflows/generate-sense/roles/reviewer/index.ts similarity index 100% rename from workflows/gitea-issue-solver/roles/reviewer/index.ts rename to workflows/generate-sense/roles/reviewer/index.ts diff --git a/workflows/gitea-issue-solver/roles/reviewer/prompt.ts b/workflows/generate-sense/roles/reviewer/prompt.ts similarity index 100% rename from workflows/gitea-issue-solver/roles/reviewer/prompt.ts rename to workflows/generate-sense/roles/reviewer/prompt.ts diff --git a/workflows/sense-generator/roles/tester/index.ts b/workflows/generate-sense/roles/tester/index.ts similarity index 100% rename from workflows/sense-generator/roles/tester/index.ts rename to workflows/generate-sense/roles/tester/index.ts diff --git a/workflows/sense-generator/roles/tester/prompt.ts b/workflows/generate-sense/roles/tester/prompt.ts similarity index 100% rename from workflows/sense-generator/roles/tester/prompt.ts rename to workflows/generate-sense/roles/tester/prompt.ts diff --git a/workflows/sense-generator/tsconfig.json b/workflows/generate-sense/tsconfig.json similarity index 100% rename from workflows/sense-generator/tsconfig.json rename to workflows/generate-sense/tsconfig.json diff --git a/workflows/sense-generator/.gitignore b/workflows/generate-workflow/.gitignore similarity index 100% rename from workflows/sense-generator/.gitignore rename to workflows/generate-workflow/.gitignore diff --git a/workflows/workflow-generator/build.ts b/workflows/generate-workflow/build.ts similarity index 97% rename from workflows/workflow-generator/build.ts rename to workflows/generate-workflow/build.ts index 06b46e4..1487991 100644 --- a/workflows/workflow-generator/build.ts +++ b/workflows/generate-workflow/build.ts @@ -19,7 +19,7 @@ export function buildWorkflowGenerator({ nerveRoot, }: BuildWorkflowGeneratorDeps): WorkflowDefinition { return { - name: "workflow-generator", + name: "generate-workflow", roles: { planner: buildPlannerRole({ provider, cwd: nerveRoot }), coder: buildCoderRole({ provider, cwd: nerveRoot }), diff --git a/workflows/workflow-generator/index.ts b/workflows/generate-workflow/index.ts similarity index 100% rename from workflows/workflow-generator/index.ts rename to workflows/generate-workflow/index.ts diff --git a/workflows/workflow-generator/moderator.ts b/workflows/generate-workflow/moderator.ts similarity index 100% rename from workflows/workflow-generator/moderator.ts rename to workflows/generate-workflow/moderator.ts diff --git a/workflows/workflow-generator/package.json b/workflows/generate-workflow/package.json similarity index 91% rename from workflows/workflow-generator/package.json rename to workflows/generate-workflow/package.json index b5710be..166f103 100644 --- a/workflows/workflow-generator/package.json +++ b/workflows/generate-workflow/package.json @@ -1,5 +1,5 @@ { - "name": "workflow-generator-workflow", + "name": "generate-workflow-workflow", "version": "0.0.1", "private": true, "type": "module", diff --git a/workflows/workflow-generator/roles/coder/index.ts b/workflows/generate-workflow/roles/coder/index.ts similarity index 100% rename from workflows/workflow-generator/roles/coder/index.ts rename to workflows/generate-workflow/roles/coder/index.ts diff --git a/workflows/workflow-generator/roles/coder/prompt.ts b/workflows/generate-workflow/roles/coder/prompt.ts similarity index 100% rename from workflows/workflow-generator/roles/coder/prompt.ts rename to workflows/generate-workflow/roles/coder/prompt.ts diff --git a/workflows/workflow-generator/roles/committer/index.ts b/workflows/generate-workflow/roles/committer/index.ts similarity index 100% rename from workflows/workflow-generator/roles/committer/index.ts rename to workflows/generate-workflow/roles/committer/index.ts diff --git a/workflows/workflow-generator/roles/committer/prompt.ts b/workflows/generate-workflow/roles/committer/prompt.ts similarity index 100% rename from workflows/workflow-generator/roles/committer/prompt.ts rename to workflows/generate-workflow/roles/committer/prompt.ts diff --git a/workflows/workflow-generator/roles/planner/index.ts b/workflows/generate-workflow/roles/planner/index.ts similarity index 100% rename from workflows/workflow-generator/roles/planner/index.ts rename to workflows/generate-workflow/roles/planner/index.ts diff --git a/workflows/workflow-generator/roles/planner/prompt.ts b/workflows/generate-workflow/roles/planner/prompt.ts similarity index 100% rename from workflows/workflow-generator/roles/planner/prompt.ts rename to workflows/generate-workflow/roles/planner/prompt.ts diff --git a/workflows/sense-generator/roles/reviewer/index.ts b/workflows/generate-workflow/roles/reviewer/index.ts similarity index 100% rename from workflows/sense-generator/roles/reviewer/index.ts rename to workflows/generate-workflow/roles/reviewer/index.ts diff --git a/workflows/sense-generator/roles/reviewer/prompt.ts b/workflows/generate-workflow/roles/reviewer/prompt.ts similarity index 100% rename from workflows/sense-generator/roles/reviewer/prompt.ts rename to workflows/generate-workflow/roles/reviewer/prompt.ts diff --git a/workflows/workflow-generator/roles/tester/index.ts b/workflows/generate-workflow/roles/tester/index.ts similarity index 100% rename from workflows/workflow-generator/roles/tester/index.ts rename to workflows/generate-workflow/roles/tester/index.ts diff --git a/workflows/workflow-generator/roles/tester/prompt.ts b/workflows/generate-workflow/roles/tester/prompt.ts similarity index 100% rename from workflows/workflow-generator/roles/tester/prompt.ts rename to workflows/generate-workflow/roles/tester/prompt.ts diff --git a/workflows/gitea-issue-solver/tsconfig.json b/workflows/generate-workflow/tsconfig.json similarity index 100% rename from workflows/gitea-issue-solver/tsconfig.json rename to workflows/generate-workflow/tsconfig.json diff --git a/workflows/gitea-issue-solver/build.ts b/workflows/gitea-issue-solver/build.ts deleted file mode 100644 index c102767..0000000 --- a/workflows/gitea-issue-solver/build.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { WorkflowDefinition } from "@uncaged/nerve-core"; -import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; -import { moderator } from "./moderator.js"; -import type { WorkflowMeta } from "./moderator.js"; -import { buildIntakeRole } from "./roles/intake/index.js"; -import { buildIssueReaderRole } from "./roles/issue-reader/index.js"; -import { buildPlannerRole } from "./roles/planner/index.js"; -import { buildImplementerRole } from "./roles/implementer/index.js"; -import { buildReviewerRole } from "./roles/reviewer/index.js"; -import { buildTesterRole } from "./roles/tester/index.js"; -import { buildPrPublisherRole } from "./roles/pr-publisher/index.js"; - -export type BuildGiteaIssueSolverDeps = { - nerveRoot: string; - provider: LlmProvider | null; -}; - -export function buildGiteaIssueSolver({ nerveRoot, provider }: BuildGiteaIssueSolverDeps): WorkflowDefinition { - return { - name: "gitea-issue-solver", - roles: { - intake: buildIntakeRole(), - "issue-reader": buildIssueReaderRole({ nerveRoot }), - planner: buildPlannerRole({ nerveRoot }), - implementer: buildImplementerRole({ nerveRoot }), - reviewer: provider ? buildReviewerRole({ provider, nerveRoot }) : buildReviewerRole({ provider: { apiKey: "", baseUrl: "", model: "" }, nerveRoot }), - tester: buildTesterRole({ nerveRoot }), - "pr-publisher": buildPrPublisherRole({ nerveRoot }), - }, - moderator, - }; -} diff --git a/workflows/gitea-issue-solver/index.ts b/workflows/gitea-issue-solver/index.ts deleted file mode 100644 index bd1edd6..0000000 --- a/workflows/gitea-issue-solver/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { join } from "node:path"; -import { buildGiteaIssueSolver } from "./build.js"; -import { resolveDashScopeProvider } from "./lib/provider.js"; - -const HOME = process.env.HOME ?? "/home/azureuser"; -const NERVE_ROOT = join(HOME, ".uncaged-nerve"); - -const provider = await resolveDashScopeProvider(NERVE_ROOT); - -const workflow = buildGiteaIssueSolver({ nerveRoot: NERVE_ROOT, provider }); - -export default workflow; diff --git a/workflows/gitea-issue-solver/lib/constants.ts b/workflows/gitea-issue-solver/lib/constants.ts deleted file mode 100644 index 2ce0ab4..0000000 --- a/workflows/gitea-issue-solver/lib/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const ISSUE_READER_MAX_ATTEMPTS = 3; -export const TESTER_MAX_ATTEMPTS = 3; -export const PUBLISHER_MAX_ATTEMPTS = 3; diff --git a/workflows/gitea-issue-solver/lib/meta-helpers.ts b/workflows/gitea-issue-solver/lib/meta-helpers.ts deleted file mode 100644 index f1d4c3b..0000000 --- a/workflows/gitea-issue-solver/lib/meta-helpers.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { WorkflowMessage } from "@uncaged/nerve-core"; - -export 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; -} diff --git a/workflows/gitea-issue-solver/lib/nerve-read.ts b/workflows/gitea-issue-solver/lib/nerve-read.ts deleted file mode 100644 index c83ba53..0000000 --- a/workflows/gitea-issue-solver/lib/nerve-read.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { readNerveYaml } from "@uncaged/nerve-workflow-utils"; - -export function readNerveConfigText(nerveRoot: string): string { - const result = readNerveYaml({ nerveRoot }); - return result.ok ? result.value : "# nerve.yaml unavailable"; -} diff --git a/workflows/gitea-issue-solver/lib/provider.ts b/workflows/gitea-issue-solver/lib/provider.ts deleted file mode 100644 index 53e20ce..0000000 --- a/workflows/gitea-issue-solver/lib/provider.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { spawnSafe } from "@uncaged/nerve-workflow-utils"; - -export type Provider = { - baseUrl: string; - apiKey: string; - model: string; -}; - -export async function cfgGet(nerveRoot: string, key: string): Promise { - const result = await spawnSafe("cfg", ["get", key], { cwd: nerveRoot, env: null, timeoutMs: 10_000 }); - if (!result.ok) { - return null; - } - const value = result.value.stdout.trim(); - return value.length > 0 ? value : null; -} - -export async function resolveDashScopeProvider(nerveRoot: string): Promise { - const apiKey = process.env.DASHSCOPE_API_KEY ?? (await cfgGet(nerveRoot, "DASHSCOPE_API_KEY")); - const baseUrl = process.env.DASHSCOPE_BASE_URL ?? (await cfgGet(nerveRoot, "DASHSCOPE_BASE_URL")); - const model = process.env.DASHSCOPE_MODEL ?? (await cfgGet(nerveRoot, "DASHSCOPE_MODEL")) ?? "qwen-plus"; - if (!apiKey || !baseUrl) { - return null; - } - return { apiKey, baseUrl, model }; -} diff --git a/workflows/gitea-issue-solver/lib/run-test-command.ts b/workflows/gitea-issue-solver/lib/run-test-command.ts deleted file mode 100644 index a43bdd4..0000000 --- a/workflows/gitea-issue-solver/lib/run-test-command.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { spawnSafe } from "@uncaged/nerve-workflow-utils"; -import { formatSpawnFailure } from "./spawn-utils.js"; - -export async function runTestCommand( - nerveRoot: string, - command: string, -): Promise<{ - ok: boolean; - stdoutPreview: string; - stderrPreview: string; - reason: string | null; -}> { - const result = await spawnSafe("bash", ["-lc", command], { - cwd: nerveRoot, - 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), - }; -} diff --git a/workflows/gitea-issue-solver/lib/run-with-retries.ts b/workflows/gitea-issue-solver/lib/run-with-retries.ts deleted file mode 100644 index 74c25e6..0000000 --- a/workflows/gitea-issue-solver/lib/run-with-retries.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { SpawnError } from "@uncaged/nerve-workflow-utils"; - -export 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 }; -} diff --git a/workflows/gitea-issue-solver/lib/spawn-utils.ts b/workflows/gitea-issue-solver/lib/spawn-utils.ts deleted file mode 100644 index d6cafb4..0000000 --- a/workflows/gitea-issue-solver/lib/spawn-utils.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { SpawnError } from "@uncaged/nerve-workflow-utils"; - -export 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)}`; -} - -export 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(); -} - -export 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" }; -} - -export 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"; -} diff --git a/workflows/gitea-issue-solver/lib/text-utils.ts b/workflows/gitea-issue-solver/lib/text-utils.ts deleted file mode 100644 index a4e7255..0000000 --- a/workflows/gitea-issue-solver/lib/text-utils.ts +++ /dev/null @@ -1,13 +0,0 @@ -export 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"; -} - -export 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) }; -} diff --git a/workflows/gitea-issue-solver/moderator.ts b/workflows/gitea-issue-solver/moderator.ts deleted file mode 100644 index 402cfee..0000000 --- a/workflows/gitea-issue-solver/moderator.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { END } from "@uncaged/nerve-core"; -import type { Moderator } from "@uncaged/nerve-core"; -import type { IntakeMeta } from "./roles/intake/index.js"; -import type { IssueReaderMeta } from "./roles/issue-reader/index.js"; -import type { PlannerMeta } from "./roles/planner/index.js"; -import type { ImplementerMeta } from "./roles/implementer/index.js"; -import type { ReviewerMeta } from "./roles/reviewer/index.js"; -import type { TesterMeta } from "./roles/tester/index.js"; -import type { PrPublisherMeta } from "./roles/pr-publisher/index.js"; - -export type WorkflowMeta = { - intake: IntakeMeta; - "issue-reader": IssueReaderMeta; - planner: PlannerMeta; - implementer: ImplementerMeta; - reviewer: ReviewerMeta; - tester: TesterMeta; - "pr-publisher": PrPublisherMeta; -}; - -const MAX_IMPLEMENTER_ROUNDS = 20; -const MAX_TOTAL_REJECTIONS = 10; - -function implementerRounds(steps: { role: string }[]): number { - return steps.filter((s) => s.role === "implementer").length; -} - -function totalRejections(steps: { role: string; meta: unknown }[]): number { - return steps.filter((s) => { - if (s.role === "reviewer") return !(s.meta as Record).approved; - if (s.role === "tester") return !(s.meta as Record).passed; - return false; - }).length; -} - -function canRetryImplementer(steps: { role: string; meta: unknown }[]): boolean { - return implementerRounds(steps) < MAX_IMPLEMENTER_ROUNDS && totalRejections(steps) < MAX_TOTAL_REJECTIONS; -} - -export const moderator: Moderator = (context) => { - 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"]; - return meta.fetchOk ? "planner" : END; - } - - if (last.role === "planner") { - const meta = last.meta as WorkflowMeta["planner"]; - return meta.planningOk ? "implementer" : END; - } - - if (last.role === "implementer") { - const meta = last.meta as WorkflowMeta["implementer"]; - if (meta.implementationOk) return "reviewer"; - return canRetryImplementer(context.steps) ? "implementer" : END; - } - - if (last.role === "reviewer") { - if (last.meta.approved) return "tester"; - return canRetryImplementer(context.steps) ? "implementer" : END; - } - - if (last.role === "tester") { - const meta = last.meta as WorkflowMeta["tester"]; - if (meta.passed) return "pr-publisher"; - return canRetryImplementer(context.steps) ? "implementer" : END; - } - - if (last.role === "pr-publisher") { - return END; - } - - return END; -}; diff --git a/workflows/gitea-issue-solver/package.json b/workflows/gitea-issue-solver/package.json deleted file mode 100644 index 78fe8bd..0000000 --- a/workflows/gitea-issue-solver/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "gitea-issue-solver-workflow", - "version": "0.0.1", - "private": true, - "type": "module", - "scripts": { - "build": "esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external" - }, - "dependencies": { - "@uncaged/nerve-core": "latest", - "@uncaged/nerve-workflow-utils": "latest", - "zod": "^4.3.6" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "esbuild": "^0.27.0", - "typescript": "^5.7.0" - } -} diff --git a/workflows/gitea-issue-solver/roles/implementer/index.ts b/workflows/gitea-issue-solver/roles/implementer/index.ts deleted file mode 100644 index b7bb124..0000000 --- a/workflows/gitea-issue-solver/roles/implementer/index.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; -import { cursorAgent, spawnSafe } from "@uncaged/nerve-workflow-utils"; -import { lastMetaForRole } from "../../lib/meta-helpers.js"; -import { formatSpawnFailure } from "../../lib/spawn-utils.js"; -import { slugify } from "../../lib/text-utils.js"; -import type { IntakeMeta } from "../intake/index.js"; -import type { IssueReaderMeta } from "../issue-reader/index.js"; -import type { PlannerMeta } from "../planner/index.js"; -import { buildImplementerPrompt } from "./prompt.js"; - -export type ImplementerMeta = { - branchName: string | null; - changedFiles: string[] | null; - implementationOk: boolean; - attempt: number; - failureKind: "none" | "branch_failed" | "agent_failed" | "no_diff"; - failureReason: string | null; - implementationLog: string | null; -}; - -export type BuildImplementerDeps = { - nerveRoot: string; -}; - -export function buildImplementerRole({ nerveRoot }: BuildImplementerDeps): Role { - return async (_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: nerveRoot, - env: null, - timeoutMs: 10_000, - }); - const checkoutResult = existsResult.ok - ? await spawnSafe("git", ["checkout", branchName], { cwd: nerveRoot, env: null, timeoutMs: 20_000 }) - : await spawnSafe("git", ["checkout", "-b", branchName], { cwd: nerveRoot, 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 = buildImplementerPrompt({ - issueNumber: intakeMeta.issueNumber, - plan: plannerMeta.plan, - targetFilesText: plannerMeta.targetFiles?.join("\n") ?? "(not specified)", - testCommandsText: plannerMeta.testCommands?.join("\n") ?? "(not specified)", - }); - - const agentResult = await cursorAgent({ - prompt, - mode: "default", - model: "auto", - cwd: nerveRoot, - 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: nerveRoot, - 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, - }, - }; - }; -} diff --git a/workflows/gitea-issue-solver/roles/implementer/prompt.ts b/workflows/gitea-issue-solver/roles/implementer/prompt.ts deleted file mode 100644 index 31f8ba5..0000000 --- a/workflows/gitea-issue-solver/roles/implementer/prompt.ts +++ /dev/null @@ -1,21 +0,0 @@ -export function buildImplementerPrompt(params: { - issueNumber: number; - plan: string; - targetFilesText: string; - testCommandsText: string; -}): string { - const { issueNumber, plan, targetFilesText, testCommandsText } = params; - return `Implement the planned fix in this repository. - -Issue #${issueNumber} -Plan: -${plan} - -Target files: -${targetFilesText} - -Test commands: -${testCommandsText} - -Apply the code changes now with minimal, focused edits.`; -} diff --git a/workflows/gitea-issue-solver/roles/intake/index.ts b/workflows/gitea-issue-solver/roles/intake/index.ts deleted file mode 100644 index 4780e8b..0000000 --- a/workflows/gitea-issue-solver/roles/intake/index.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { Role, RoleResult, StartStep } from "@uncaged/nerve-core"; - -export type IntakeMeta = { - issueUrl: string; - host: string | null; - owner: string | null; - repo: string | null; - issueNumber: number | null; - valid: boolean; - failureKind: "none" | "invalid_input"; - failureReason: string | null; -}; - -function parseIssueUrl(raw: string): IntakeMeta { - 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, - }; -} - -export function buildIntakeRole(): Role { - return async (start: StartStep): 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, - }; - }; -} diff --git a/workflows/gitea-issue-solver/roles/intake/prompt.ts b/workflows/gitea-issue-solver/roles/intake/prompt.ts deleted file mode 100644 index 6d34733..0000000 --- a/workflows/gitea-issue-solver/roles/intake/prompt.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Intake role parses the issue URL from the workflow start message; no LLM prompt. */ -export function intakePrompt(): string { - return ""; -} diff --git a/workflows/gitea-issue-solver/roles/issue-reader/index.ts b/workflows/gitea-issue-solver/roles/issue-reader/index.ts deleted file mode 100644 index 785df91..0000000 --- a/workflows/gitea-issue-solver/roles/issue-reader/index.ts +++ /dev/null @@ -1,259 +0,0 @@ -import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; -import { llmExtract, spawnSafe } from "@uncaged/nerve-workflow-utils"; -import { z } from "zod"; -import { ISSUE_READER_MAX_ATTEMPTS } from "../../lib/constants.js"; -import { lastMetaForRole } from "../../lib/meta-helpers.js"; -import type { Provider } from "../../lib/provider.js"; -import { resolveDashScopeProvider as resolveProvider } from "../../lib/provider.js"; -import { runWithRetries } from "../../lib/run-with-retries.js"; -import { classifyIssueReaderFailure, formatSpawnFailure } from "../../lib/spawn-utils.js"; -import type { IntakeMeta } from "../intake/index.js"; - -export type IssueData = { - id: number; - number: number; - title: string; - body: string; - state: string; - labels: string[]; - comments: Array<{ - author: string; - body: string; - createdAt: string; - }>; - url: string; -}; - -export type IssueReaderMeta = { - issue: IssueData | null; - fetchOk: boolean; - transientError: boolean; - attempt: number; - failureKind: "none" | "transient" | "permanent"; - failureReason: string | null; -}; - -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(""), -}); - -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; -} - -export type BuildIssueReaderDeps = { - nerveRoot: string; -}; - -export function buildIssueReaderRole({ nerveRoot }: BuildIssueReaderDeps): Role { - return async (_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: nerveRoot, - 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: nerveRoot, - 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 resolveProvider(nerveRoot); - 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), - }, - }; - }; -} diff --git a/workflows/gitea-issue-solver/roles/issue-reader/prompt.ts b/workflows/gitea-issue-solver/roles/issue-reader/prompt.ts deleted file mode 100644 index 5eeb14f..0000000 --- a/workflows/gitea-issue-solver/roles/issue-reader/prompt.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Issue reader fetches issue data via tea CLI; no separate LLM prompt. */ -export function issueReaderPrompt(): string { - return ""; -} diff --git a/workflows/gitea-issue-solver/roles/planner/index.ts b/workflows/gitea-issue-solver/roles/planner/index.ts deleted file mode 100644 index 39cf7e3..0000000 --- a/workflows/gitea-issue-solver/roles/planner/index.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; -import { cursorAgent, llmExtract } from "@uncaged/nerve-workflow-utils"; -import { z } from "zod"; -import { readNerveConfigText } from "../../lib/nerve-read.js"; -import { resolveDashScopeProvider } from "../../lib/provider.js"; -import { lastMetaForRole } from "../../lib/meta-helpers.js"; -import { formatSpawnFailure } from "../../lib/spawn-utils.js"; -import type { IssueReaderMeta } from "../issue-reader/index.js"; -import { buildPlannerPrompt } from "./prompt.js"; - -export type PlannerMeta = { - plan: string | null; - targetFiles: string[] | null; - testCommands: string[] | null; - riskNotes: string[] | null; - planningOk: boolean; - failureKind: "none" | "planning_failed"; - failureReason: string | null; -}; - -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([]), -}); - -export type BuildPlannerDeps = { - nerveRoot: string; -}; - -export function buildPlannerRole({ nerveRoot }: BuildPlannerDeps): Role { - return async (_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 = buildPlannerPrompt({ - issue: issueMeta.issue, - nerveYamlText: readNerveConfigText(nerveRoot), - }); - - const result = await cursorAgent({ - prompt, - mode: "ask", - model: "auto", - cwd: nerveRoot, - 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(nerveRoot); - 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, - }, - }; - }; -} diff --git a/workflows/gitea-issue-solver/roles/planner/prompt.ts b/workflows/gitea-issue-solver/roles/planner/prompt.ts deleted file mode 100644 index dedef3d..0000000 --- a/workflows/gitea-issue-solver/roles/planner/prompt.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { nerveAgentContext } from "@uncaged/nerve-workflow-utils"; -import type { IssueData } from "../issue-reader/index.js"; - -export function buildPlannerPrompt(params: { - issue: IssueData; - nerveYamlText: string; -}): string { - const { issue, nerveYamlText } = params; - return `You are planning a fix for a Gitea issue in this repository. - -${nerveAgentContext} - -Issue URL: ${issue.url} -Issue title: ${issue.title} -Issue body: -${issue.body} - -Issue comments: -${issue.comments.map((c) => `- ${c.author} (${c.createdAt}): ${c.body}`).join("\n") || "(none)"} - -Current nerve.yaml: -\`\`\`yaml -${nerveYamlText} -\`\`\` - -Output implementation-ready markdown with sections: -1) Problem understanding -2) Change strategy -3) Test strategy (commands) -4) Risks`; -} diff --git a/workflows/gitea-issue-solver/roles/pr-publisher/index.ts b/workflows/gitea-issue-solver/roles/pr-publisher/index.ts deleted file mode 100644 index e3e32ee..0000000 --- a/workflows/gitea-issue-solver/roles/pr-publisher/index.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; -import { spawnSafe } from "@uncaged/nerve-workflow-utils"; -import { PUBLISHER_MAX_ATTEMPTS } from "../../lib/constants.js"; -import { lastMetaForRole } from "../../lib/meta-helpers.js"; -import { runWithRetries } from "../../lib/run-with-retries.js"; -import { classifyPublisherFailure, formatSpawnFailure } from "../../lib/spawn-utils.js"; -import { extractPrInfo } from "../../lib/text-utils.js"; -import type { ImplementerMeta } from "../implementer/index.js"; -import type { IntakeMeta } from "../intake/index.js"; -import type { IssueReaderMeta } from "../issue-reader/index.js"; -import type { PlannerMeta } from "../planner/index.js"; -import type { TesterMeta } from "../tester/index.js"; - -export type PrPublisherMeta = { - prUrl: string | null; - prNumber: number | null; - linkedIssue: string | null; - published: boolean; - attempt: number; - failureKind: "none" | "auth_or_network" | "permanent"; - failureReason: string | null; -}; - -export type BuildPrPublisherDeps = { - nerveRoot: string; -}; - -export function buildPrPublisherRole({ nerveRoot }: BuildPrPublisherDeps): Role { - return async (_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: nerveRoot, - 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: nerveRoot, - 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, - }, - }; - }; -} diff --git a/workflows/gitea-issue-solver/roles/pr-publisher/prompt.ts b/workflows/gitea-issue-solver/roles/pr-publisher/prompt.ts deleted file mode 100644 index ab918a1..0000000 --- a/workflows/gitea-issue-solver/roles/pr-publisher/prompt.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** PR publisher runs git push and tea pr create; no LLM prompt. */ -export function prPublisherPrompt(): string { - return ""; -} diff --git a/workflows/gitea-issue-solver/roles/tester/index.ts b/workflows/gitea-issue-solver/roles/tester/index.ts deleted file mode 100644 index 21c791a..0000000 --- a/workflows/gitea-issue-solver/roles/tester/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; -import { lastMetaForRole } from "../../lib/meta-helpers.js"; -import { runTestCommand } from "../../lib/run-test-command.js"; -import type { ImplementerMeta } from "../implementer/index.js"; -import type { PlannerMeta } from "../planner/index.js"; - -export type TestCommandResult = { - command: string; - ok: boolean; - stdoutPreview: string; - stderrPreview: string; -}; - -export type TesterMeta = { - passed: boolean; - attempt: number; - failureReason: string | null; - testCommandResults: TestCommandResult[] | null; -}; - -export type BuildTesterDeps = { - nerveRoot: string; -}; - -export function buildTesterRole({ nerveRoot }: BuildTesterDeps): Role { - return async (_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(nerveRoot, 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, - }, - }; - }; -} diff --git a/workflows/gitea-issue-solver/roles/tester/prompt.ts b/workflows/gitea-issue-solver/roles/tester/prompt.ts deleted file mode 100644 index 116b09e..0000000 --- a/workflows/gitea-issue-solver/roles/tester/prompt.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Tester runs shell test commands; no LLM prompt. */ -export function testerPrompt(): string { - return ""; -} diff --git a/workflows/workflow-generator/.gitignore b/workflows/workflow-generator/.gitignore deleted file mode 100644 index 849ddff..0000000 --- a/workflows/workflow-generator/.gitignore +++ /dev/null @@ -1 +0,0 @@ -dist/ diff --git a/workflows/workflow-generator/roles/reviewer/index.ts b/workflows/workflow-generator/roles/reviewer/index.ts deleted file mode 100644 index 9148076..0000000 --- a/workflows/workflow-generator/roles/reviewer/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; -import { createHermesRole } from "@uncaged/nerve-workflow-utils"; -import { reviewerPrompt } from "./prompt.js"; -import { z } from "zod"; - -export const reviewerMetaSchema = z.object({ - approved: z.boolean().describe("true if the diff is clean and ready for tester validation"), -}); -export type ReviewerMeta = z.infer; - -export type BuildReviewerDeps = { - provider: LlmProvider; - nerveRoot: string; -}; - -export function buildReviewerRole({ provider, nerveRoot }: BuildReviewerDeps) { - return createHermesRole({ - prompt: async (threadId) => reviewerPrompt({ threadId, nerveRoot }), - extract: { provider, schema: reviewerMetaSchema }, - }); -} diff --git a/workflows/workflow-generator/roles/reviewer/prompt.ts b/workflows/workflow-generator/roles/reviewer/prompt.ts deleted file mode 100644 index ed8cdf9..0000000 --- a/workflows/workflow-generator/roles/reviewer/prompt.ts +++ /dev/null @@ -1,38 +0,0 @@ -export function reviewerPrompt({ threadId, nerveRoot }: { threadId: string; nerveRoot: string }): string { - return `You are a **code reviewer** for Nerve workflow changes. You run after the coder and before the tester. - -**IMPORTANT: The Nerve workspace is at \`${nerveRoot}\`. Always \`cd ${nerveRoot}\` first.** - -Read the workflow thread for context: \`nerve thread ${threadId}\` -Read project conventions: \`cat ${nerveRoot}/CONVENTIONS.md\` - -## Your job — static analysis of the git diff - -Run these commands and analyze the output: - -1. **\`cd ${nerveRoot} && git diff --stat\`** — see what files changed -2. **\`cd ${nerveRoot} && git diff\`** — read the actual diff -3. **\`cd ${nerveRoot} && git status --short\`** — check for untracked files - -## Checklist - -Review the diff against CONVENTIONS.md. Key things to catch: - -### 🔴 Reject (approved: false) — tell coder exactly what to fix -- **Garbage files**: anything listed under "What NOT to commit" in CONVENTIONS.md -- **Secrets/credentials**: API keys, tokens, passwords hardcoded in the diff -- **Unrelated changes**: files modified outside the scope of the task -- **Convention violations**: patterns that contradict CONVENTIONS.md (e.g. \`interface\` instead of \`type\`, \`class\`, dynamic \`import()\`, optional properties with \`?:\`) - -### ✅ Approve (approved: true) — no comment needed -- Diff is clean, focused, follows conventions - -End with: -\`\`\`json -{ "approved": true } -\`\`\` -or -\`\`\`json -{ "approved": false } -\`\`\``; -} diff --git a/workflows/workflow-generator/tsconfig.json b/workflows/workflow-generator/tsconfig.json deleted file mode 100644 index fc00159..0000000 --- a/workflows/workflow-generator/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "skipLibCheck": true, - "noEmit": true, - "types": ["node"] - }, - "include": ["./**/*.ts"] -}