From 95587260f6d20d915a911224ddbf296e2bfa0cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Tue, 28 Apr 2026 16:02:16 +0000 Subject: [PATCH] chore(workflow): auto-generated commit --- nerve.yaml | 3 + pnpm-lock.yaml | 22 +++ workflows/solve-issue/.gitignore | 1 + workflows/solve-issue/build.ts | 32 ++++ workflows/solve-issue/index.ts | 16 ++ workflows/solve-issue/lib/provider.ts | 21 +++ workflows/solve-issue/lib/repo-context.ts | 110 ++++++++++++++ workflows/solve-issue/lib/spawn-utils.ts | 11 ++ workflows/solve-issue/lib/start-meta.ts | 7 + workflows/solve-issue/moderator.ts | 89 +++++++++++ workflows/solve-issue/package.json | 19 +++ .../solve-issue/roles/implement/index.ts | 64 ++++++++ .../solve-issue/roles/implement/prompt.ts | 24 +++ workflows/solve-issue/roles/plan/index.ts | 64 ++++++++ workflows/solve-issue/roles/plan/prompt.ts | 27 ++++ workflows/solve-issue/roles/prepare/index.ts | 20 +++ workflows/solve-issue/roles/prepare/prompt.ts | 40 +++++ workflows/solve-issue/roles/publish/index.ts | 138 ++++++++++++++++++ .../solve-issue/roles/read-issue/index.ts | 20 +++ .../solve-issue/roles/read-issue/prompt.ts | 34 +++++ workflows/solve-issue/roles/review/index.ts | 21 +++ workflows/solve-issue/roles/review/prompt.ts | 35 +++++ workflows/solve-issue/roles/test/index.ts | 20 +++ workflows/solve-issue/roles/test/prompt.ts | 21 +++ workflows/solve-issue/tsconfig.json | 13 ++ 25 files changed, 872 insertions(+) create mode 100644 workflows/solve-issue/.gitignore create mode 100644 workflows/solve-issue/build.ts create mode 100644 workflows/solve-issue/index.ts create mode 100644 workflows/solve-issue/lib/provider.ts create mode 100644 workflows/solve-issue/lib/repo-context.ts create mode 100644 workflows/solve-issue/lib/spawn-utils.ts create mode 100644 workflows/solve-issue/lib/start-meta.ts create mode 100644 workflows/solve-issue/moderator.ts create mode 100644 workflows/solve-issue/package.json create mode 100644 workflows/solve-issue/roles/implement/index.ts create mode 100644 workflows/solve-issue/roles/implement/prompt.ts create mode 100644 workflows/solve-issue/roles/plan/index.ts create mode 100644 workflows/solve-issue/roles/plan/prompt.ts create mode 100644 workflows/solve-issue/roles/prepare/index.ts create mode 100644 workflows/solve-issue/roles/prepare/prompt.ts create mode 100644 workflows/solve-issue/roles/publish/index.ts create mode 100644 workflows/solve-issue/roles/read-issue/index.ts create mode 100644 workflows/solve-issue/roles/read-issue/prompt.ts create mode 100644 workflows/solve-issue/roles/review/index.ts create mode 100644 workflows/solve-issue/roles/review/prompt.ts create mode 100644 workflows/solve-issue/roles/test/index.ts create mode 100644 workflows/solve-issue/roles/test/prompt.ts create mode 100644 workflows/solve-issue/tsconfig.json diff --git a/nerve.yaml b/nerve.yaml index ccefc5d..991ecc2 100644 --- a/nerve.yaml +++ b/nerve.yaml @@ -36,3 +36,6 @@ workflows: gitea-issue-solver: concurrency: 1 overflow: drop + solve-issue: + concurrency: 1 + overflow: drop diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9c2385..20934bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,28 @@ importers: specifier: ^5.7.0 version: 5.9.3 + workflows/solve-issue: + 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 + workflows/workflow-generator: dependencies: '@uncaged/nerve-core': diff --git a/workflows/solve-issue/.gitignore b/workflows/solve-issue/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/workflows/solve-issue/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/workflows/solve-issue/build.ts b/workflows/solve-issue/build.ts new file mode 100644 index 0000000..1ad70e4 --- /dev/null +++ b/workflows/solve-issue/build.ts @@ -0,0 +1,32 @@ +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 { buildImplementRole } from "./roles/implement/index.js"; +import { buildPlanRole } from "./roles/plan/index.js"; +import { buildPrepareRole } from "./roles/prepare/index.js"; +import { buildPublishRole } from "./roles/publish/index.js"; +import { buildReadIssueRole } from "./roles/read-issue/index.js"; +import { buildReviewRole } from "./roles/review/index.js"; +import { buildTestRole } from "./roles/test/index.js"; + +export type BuildSolveIssueDeps = { + nerveRoot: string; + provider: LlmProvider; +}; + +export function buildSolveIssue({ nerveRoot, provider }: BuildSolveIssueDeps): WorkflowDefinition { + return { + name: "solve-issue", + roles: { + "read-issue": buildReadIssueRole({ provider }), + prepare: buildPrepareRole({ provider }), + plan: buildPlanRole({ provider, nerveRoot }), + implement: buildImplementRole({ provider, nerveRoot }), + review: buildReviewRole({ provider, nerveRoot }), + test: buildTestRole({ provider }), + publish: buildPublishRole({ nerveRoot }), + }, + moderator, + }; +} diff --git a/workflows/solve-issue/index.ts b/workflows/solve-issue/index.ts new file mode 100644 index 0000000..c31f7ed --- /dev/null +++ b/workflows/solve-issue/index.ts @@ -0,0 +1,16 @@ +import { join } from "node:path"; +import { buildSolveIssue } 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); + +if (provider === null) { + throw new Error("Set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or cfg get equivalents)"); +} + +const workflow = buildSolveIssue({ nerveRoot: NERVE_ROOT, provider }); + +export default workflow; diff --git a/workflows/solve-issue/lib/provider.ts b/workflows/solve-issue/lib/provider.ts new file mode 100644 index 0000000..22b88a2 --- /dev/null +++ b/workflows/solve-issue/lib/provider.ts @@ -0,0 +1,21 @@ +import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; +import { spawnSafe } from "@uncaged/nerve-workflow-utils"; + +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/solve-issue/lib/repo-context.ts b/workflows/solve-issue/lib/repo-context.ts new file mode 100644 index 0000000..4ccc70a --- /dev/null +++ b/workflows/solve-issue/lib/repo-context.ts @@ -0,0 +1,110 @@ +import { join } from "node:path"; +import type { WorkflowMessage } from "@uncaged/nerve-core"; + +export type SolveIssueParse = { + host: string; + owner: string; + repo: string; + number: number; +}; + +export type SolveIssueRepo = { + path: string; + defaultBranch: string; + packageManager: string; +}; + +const HOME = process.env.HOME ?? "/home/azureuser"; + +function extractMarkedSection(text: string, marker: string): Record | null { + const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`---${escaped}---\\s*([\\s\\S]*?)(?:\\n---|$)`); + const m = text.match(re); + if (m === null) { + return null; + } + const rec: Record = {}; + for (const line of m[1].split("\n")) { + const kv = line.match(/^([a-zA-Z]+):\s*(.+)$/); + if (kv !== null) { + rec[kv[1]] = kv[2].trim(); + } + } + return Object.keys(rec).length > 0 ? rec : null; +} + +export function parseSolveIssueParse(text: string): SolveIssueParse | null { + const rec = extractMarkedSection(text, "SOLVE_ISSUE_PARSE"); + if (rec === null) { + return null; + } + const host = rec.host ?? ""; + const owner = rec.owner ?? ""; + const repo = rec.repo ?? ""; + const num = Number(rec.number ?? ""); + if (host.length === 0 || owner.length === 0 || repo.length === 0 || !Number.isFinite(num) || num <= 0) { + return null; + } + return { host, owner, repo, number: num }; +} + +export function parseSolveIssueRepo(text: string): SolveIssueRepo | null { + const rec = extractMarkedSection(text, "SOLVE_ISSUE_REPO"); + if (rec === null) { + return null; + } + const path = rec.path ?? ""; + if (path.length === 0) { + return null; + } + return { + path, + defaultBranch: rec.defaultBranch ?? "main", + packageManager: rec.packageManager ?? "pnpm", + }; +} + +/** Prefer explicit prepare marker; else ~/Code// from read-issue parse block. */ +export function resolveRepoCwd(messages: WorkflowMessage[]): string | null { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "prepare") { + const repo = parseSolveIssueRepo(messages[i].content); + if (repo !== null) { + return repo.path; + } + } + } + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "read-issue") { + const parsed = parseSolveIssueParse(messages[i].content); + if (parsed !== null) { + return join(HOME, "Code", parsed.owner, parsed.repo); + } + } + } + return null; +} + +export function lastParseFromMessages(messages: WorkflowMessage[]): SolveIssueParse | null { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "read-issue") { + const parsed = parseSolveIssueParse(messages[i].content); + if (parsed !== null) { + return parsed; + } + } + } + return null; +} + +export function lastRepoFromMessages(messages: WorkflowMessage[]): SolveIssueRepo | null { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "prepare") { + const repo = parseSolveIssueRepo(messages[i].content); + if (repo !== null) { + return repo; + } + } + } + return null; +} diff --git a/workflows/solve-issue/lib/spawn-utils.ts b/workflows/solve-issue/lib/spawn-utils.ts new file mode 100644 index 0000000..c196c1b --- /dev/null +++ b/workflows/solve-issue/lib/spawn-utils.ts @@ -0,0 +1,11 @@ +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)}`; +} diff --git a/workflows/solve-issue/lib/start-meta.ts b/workflows/solve-issue/lib/start-meta.ts new file mode 100644 index 0000000..f92082c --- /dev/null +++ b/workflows/solve-issue/lib/start-meta.ts @@ -0,0 +1,7 @@ +import type { StartStep } from "@uncaged/nerve-core"; + +/** Runtime may include threadId before types catch up in published nerve-core. */ +export function threadIdFromStart(start: StartStep): string { + const m = start.meta as { threadId?: string }; + return typeof m.threadId === "string" && m.threadId.length > 0 ? m.threadId : "unknown"; +} diff --git a/workflows/solve-issue/moderator.ts b/workflows/solve-issue/moderator.ts new file mode 100644 index 0000000..02714f3 --- /dev/null +++ b/workflows/solve-issue/moderator.ts @@ -0,0 +1,89 @@ +import { END } from "@uncaged/nerve-core"; +import type { Moderator } from "@uncaged/nerve-core"; +import type { ReadIssueMeta } from "./roles/read-issue/index.js"; +import type { PrepareMeta } from "./roles/prepare/index.js"; +import type { PlanMeta } from "./roles/plan/index.js"; +import type { ImplementMeta } from "./roles/implement/index.js"; +import type { ReviewMeta } from "./roles/review/index.js"; +import type { TestMeta } from "./roles/test/index.js"; +import type { PublishMeta } from "./roles/publish/index.js"; + +export type WorkflowMeta = { + "read-issue": ReadIssueMeta; + prepare: PrepareMeta; + plan: PlanMeta; + implement: ImplementMeta; + review: ReviewMeta; + test: TestMeta; + publish: PublishMeta; +}; + +const MAX_IMPLEMENT_ROUNDS = 20; +const MAX_TOTAL_REJECTIONS = 10; + +function implementRounds(steps: { role: string }[]): number { + return steps.filter((s) => s.role === "implement").length; +} + +function totalRejections(steps: { role: string; meta: unknown }[]): number { + return steps.filter((s) => { + if (s.role === "review") return !(s.meta as Record).approved; + if (s.role === "test") return !(s.meta as Record).passed; + if (s.role === "publish") return !(s.meta as Record).success; + return false; + }).length; +} + +function canRetryImplement(steps: { role: string; meta: unknown }[]): boolean { + return implementRounds(steps) < MAX_IMPLEMENT_ROUNDS && totalRejections(steps) < MAX_TOTAL_REJECTIONS; +} + +export const moderator: Moderator = (context) => { + if (context.steps.length === 0) { + return "read-issue"; + } + + const last = context.steps[context.steps.length - 1]; + + if (last.role === "read-issue") { + return last.meta.ready ? "prepare" : END; + } + + if (last.role === "prepare") { + return last.meta.ready ? "plan" : END; + } + + if (last.role === "plan") { + return last.meta.ready ? "implement" : END; + } + + if (last.role === "implement") { + if (last.meta.done) { + return "review"; + } + return canRetryImplement(context.steps) ? "implement" : END; + } + + if (last.role === "review") { + if (last.meta.approved) { + return "test"; + } + return canRetryImplement(context.steps) ? "implement" : END; + } + + if (last.role === "test") { + if (last.meta.passed) { + return "publish"; + } + return canRetryImplement(context.steps) ? "implement" : END; + } + + if (last.role === "publish") { + if (last.meta.success) { + return END; + } + return canRetryImplement(context.steps) ? "implement" : END; + } + + return END; +}; diff --git a/workflows/solve-issue/package.json b/workflows/solve-issue/package.json new file mode 100644 index 0000000..dee9b0d --- /dev/null +++ b/workflows/solve-issue/package.json @@ -0,0 +1,19 @@ +{ + "name": "solve-issue-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/solve-issue/roles/implement/index.ts b/workflows/solve-issue/roles/implement/index.ts new file mode 100644 index 0000000..a30b16c --- /dev/null +++ b/workflows/solve-issue/roles/implement/index.ts @@ -0,0 +1,64 @@ +import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; +import { cursorAgent, isDryRun, llmExtract } from "@uncaged/nerve-workflow-utils"; +import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; +import { resolveRepoCwd } from "../../lib/repo-context.js"; +import { threadIdFromStart } from "../../lib/start-meta.js"; +import { formatSpawnFailure } from "../../lib/spawn-utils.js"; +import { buildImplementPrompt } from "./prompt.js"; + +export const implementMetaSchema = z.object({ + done: z.boolean().describe("true when changes are complete and build passes this round"), +}); +export type ImplementMeta = z.infer; + +export type BuildImplementDeps = { + provider: LlmProvider; + nerveRoot: string; +}; + +export function buildImplementRole({ provider, nerveRoot }: BuildImplementDeps): Role { + return async (start: StartStep, messages: WorkflowMessage[]): Promise> => { + const cwd = resolveRepoCwd(messages); + if (cwd === null) { + return { + content: "implement cannot run: missing repo path in thread markers", + meta: { done: false }, + }; + } + + const dry = isDryRun(start); + const prompt = buildImplementPrompt({ threadId: threadIdFromStart(start), nerveRoot }); + const run = await cursorAgent({ + prompt, + mode: "default", + model: "auto", + cwd, + env: null, + timeoutMs: 300_000, + dryRun: dry, + }); + + if (!run.ok) { + return { + content: `implement cursor-agent failed: ${formatSpawnFailure(run.error)}`, + meta: { done: false }, + }; + } + + const metaR = await llmExtract({ + text: run.value, + schema: implementMetaSchema, + provider, + dryRun: dry, + }); + if (!metaR.ok) { + return { + content: `${run.value}\n\n[meta extract failed]`, + meta: { done: false }, + }; + } + + return { content: run.value, meta: metaR.value }; + }; +} diff --git a/workflows/solve-issue/roles/implement/prompt.ts b/workflows/solve-issue/roles/implement/prompt.ts new file mode 100644 index 0000000..5e1dc77 --- /dev/null +++ b/workflows/solve-issue/roles/implement/prompt.ts @@ -0,0 +1,24 @@ +export function buildImplementPrompt({ threadId, nerveRoot }: { threadId: string; nerveRoot: string }): string { + return `You are the **implement** agent. You apply code changes for the issue. + +Read workflow context (plan, reviewer/test feedback): \`nerve thread show ${threadId}\` + +Read Nerve workspace conventions: \`cat ${nerveRoot}/CONVENTIONS.md\` + +Your cwd is the target repository. + +## Requirements + +1. Create a branch: \`fix/issue--\` (use \`feat/\` if the issue is clearly a feature). Use a slug from the issue title (lowercase, hyphens). +2. Implement the planned changes; address reviewer/tester feedback from the thread if any. +3. Run the project **build** (\`pnpm build\`, \`npm run build\`, etc.) and fix issues until build passes. +4. Multi-step: if you cannot finish this round, explain why and set **done** to false. + +Then close with JSON: +\`\`\`json +{ "done": true } +\`\`\` +or \`{ "done": false }\` matching whether implementation is complete. + +**done=true** only when changes are complete **and** build passes in this round.`; +} diff --git a/workflows/solve-issue/roles/plan/index.ts b/workflows/solve-issue/roles/plan/index.ts new file mode 100644 index 0000000..4b6d4ce --- /dev/null +++ b/workflows/solve-issue/roles/plan/index.ts @@ -0,0 +1,64 @@ +import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; +import { cursorAgent, isDryRun, llmExtract } from "@uncaged/nerve-workflow-utils"; +import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; +import { resolveRepoCwd } from "../../lib/repo-context.js"; +import { threadIdFromStart } from "../../lib/start-meta.js"; +import { formatSpawnFailure } from "../../lib/spawn-utils.js"; +import { buildPlanPrompt } from "./prompt.js"; + +export const planMetaSchema = z.object({ + ready: z.boolean().describe("true if plan is clear and actionable"), +}); +export type PlanMeta = z.infer; + +export type BuildPlanDeps = { + provider: LlmProvider; + nerveRoot: string; +}; + +export function buildPlanRole({ provider, nerveRoot }: BuildPlanDeps): Role { + return async (start: StartStep, messages: WorkflowMessage[]): Promise> => { + const cwd = resolveRepoCwd(messages); + if (cwd === null) { + return { + content: "plan cannot run: missing ---SOLVE_ISSUE_REPO--- or ---SOLVE_ISSUE_PARSE--- in thread", + meta: { ready: false }, + }; + } + + const dry = isDryRun(start); + const prompt = buildPlanPrompt({ threadId: threadIdFromStart(start), nerveRoot }); + const run = await cursorAgent({ + prompt, + mode: "ask", + model: "auto", + cwd, + env: null, + timeoutMs: 300_000, + dryRun: dry, + }); + + if (!run.ok) { + return { + content: `plan cursor-agent failed: ${formatSpawnFailure(run.error)}`, + meta: { ready: false }, + }; + } + + const metaR = await llmExtract({ + text: run.value, + schema: planMetaSchema, + provider, + dryRun: dry, + }); + if (!metaR.ok) { + return { + content: `${run.value}\n\n[meta extract failed]`, + meta: { ready: false }, + }; + } + + return { content: run.value, meta: metaR.value }; + }; +} diff --git a/workflows/solve-issue/roles/plan/prompt.ts b/workflows/solve-issue/roles/plan/prompt.ts new file mode 100644 index 0000000..3647daf --- /dev/null +++ b/workflows/solve-issue/roles/plan/prompt.ts @@ -0,0 +1,27 @@ +export function buildPlanPrompt({ threadId, nerveRoot }: { threadId: string; nerveRoot: string }): string { + return `You are the **plan** agent (analysis only — ask mode). You produce an implementation plan for fixing the issue. + +Read workflow context: \`nerve thread show ${threadId}\` + +Read Nerve workspace conventions (coding rules for agents): \`cat ${nerveRoot}/CONVENTIONS.md\` + +In the **target repository** (your cwd), skim relevant files and read \`CONVENTIONS.md\` **if it exists** there. + +## Output + +Write an implementation plan in **markdown** with: + +1. Problem understanding +2. Change strategy +3. Target files (paths) +4. **Test commands** to run (explicit shell commands, e.g. \`pnpm test\`, \`pnpm vitest run\`) +5. Risks + +End your reply with a JSON code block (meta signal): +\`\`\`json +{ "ready": true } +\`\`\` +Use \`{ "ready": false }\` if the plan cannot be made actionable. + +**ready=true** only when the plan is clear and actionable.`; +} diff --git a/workflows/solve-issue/roles/prepare/index.ts b/workflows/solve-issue/roles/prepare/index.ts new file mode 100644 index 0000000..490393b --- /dev/null +++ b/workflows/solve-issue/roles/prepare/index.ts @@ -0,0 +1,20 @@ +import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; +import { createHermesRole } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; +import { preparePrompt } from "./prompt.js"; + +export const prepareMetaSchema = z.object({ + ready: z.boolean().describe("true if repo is ready and baseline build ok"), +}); +export type PrepareMeta = z.infer; + +export type BuildPrepareDeps = { + provider: LlmProvider; +}; + +export function buildPrepareRole({ provider }: BuildPrepareDeps) { + return createHermesRole({ + prompt: async (threadId) => preparePrompt({ threadId }), + extract: { provider, schema: prepareMetaSchema }, + }); +} diff --git a/workflows/solve-issue/roles/prepare/prompt.ts b/workflows/solve-issue/roles/prepare/prompt.ts new file mode 100644 index 0000000..942f0c4 --- /dev/null +++ b/workflows/solve-issue/roles/prepare/prompt.ts @@ -0,0 +1,40 @@ +export function preparePrompt({ threadId }: { threadId: string }): string { + return `You are the **prepare** agent. You clone or update the target repository and verify a clean baseline build. + +Read prior messages / thread for issue markers: \`nerve thread show ${threadId}\` + +## Goal + +Find **owner** and **repo** from \`---SOLVE_ISSUE_PARSE---\` in the thread (from read-issue). + +Let \`REPOPATH=$HOME/Code//\` (expand \`$HOME\`). + +## Steps + +1. \`mkdir -p "$HOME/Code/"\` +2. If \`REPOPATH/.git\` is missing: \`git clone https:////.git "$REPOPATH"\` (use host from markers; adjust scheme if needed). + Else: \`cd "$REPOPATH" && git fetch --all && git pull --ff-only\` +3. \`cd "$REPOPATH"\` — ensure working tree clean: if \`git status --porcelain\` is non-empty, \`git stash push -u -m "solve-issue stash"\` +4. Detect default branch (\`main\` or \`master\`) and \`git checkout \` +5. Detect package manager: \`pnpm-lock.yaml\` → pnpm, \`yarn.lock\` → yarn, \`package-lock.json\` → npm; run install (\`pnpm install --no-frozen-lockfile\` / \`npm ci\` or \`npm install\` / \`yarn\` as appropriate). +6. If \`package.json\` has a \`build\` script, run the build (\`pnpm build\`, etc.) and fix nothing — only verify baseline passes. + +## Required marker block + +Emit **exactly**: +\`\`\` +---SOLVE_ISSUE_REPO--- +path: +defaultBranch:
+packageManager: +--- +\`\`\` + +End with: +\`\`\`json +{ "ready": true } +\`\`\` +or \`{ "ready": false }\` if clone/install/build baseline failed. + +**ready=true** only when the repo exists at \`path\`, is clean, dependencies installed, and baseline build succeeded (or no build script).`; +} diff --git a/workflows/solve-issue/roles/publish/index.ts b/workflows/solve-issue/roles/publish/index.ts new file mode 100644 index 0000000..fd6c48f --- /dev/null +++ b/workflows/solve-issue/roles/publish/index.ts @@ -0,0 +1,138 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; +import { isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils"; +import { cfgGet } from "../../lib/provider.js"; +import { lastParseFromMessages, lastRepoFromMessages, resolveRepoCwd } from "../../lib/repo-context.js"; +import { formatSpawnFailure } from "../../lib/spawn-utils.js"; + +export type PublishMeta = { + success: boolean; +}; + +export type BuildPublishDeps = { + nerveRoot: string; +}; + +function logPath(nerveRoot: string): string { + return join(nerveRoot, "logs", `solve-issue-publish-${Date.now()}.log`); +} + +export function buildPublishRole({ nerveRoot }: BuildPublishDeps): Role { + return async (start: StartStep, messages: WorkflowMessage[]): Promise> => { + const dry = isDryRun(start); + const file = logPath(nerveRoot); + mkdirSync(join(file, ".."), { recursive: true }); + + const cwd = resolveRepoCwd(messages); + const parsed = lastParseFromMessages(messages); + const repoInfo = lastRepoFromMessages(messages); + + if (dry) { + const msg = "[dry-run] publish skipped (no git push / PR)"; + writeFileSync(file, `${msg}\n`, "utf-8"); + return { + content: `[dry-run] publish skipped — log: ${file}`, + meta: { success: true }, + }; + } + + if (cwd === null || parsed === null) { + writeFileSync(file, "missing cwd or SOLVE_ISSUE_PARSE\n", "utf-8"); + return { + content: `publish failed: missing repo path or issue markers\nLog: ${file}`, + meta: { success: false }, + }; + } + + const branchRun = await spawnSafe("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + cwd, + env: null, + timeoutMs: 15_000, + }); + if (!branchRun.ok) { + writeFileSync(file, formatSpawnFailure(branchRun.error), "utf-8"); + return { + content: `publish failed: cannot read branch — ${formatSpawnFailure(branchRun.error)}`, + meta: { success: false }, + }; + } + const branch = branchRun.value.stdout.trim(); + if (branch.length === 0) { + return { content: "publish failed: empty branch name", meta: { success: false } }; + } + + const pushRun = await spawnSafe("git", ["push", "-u", "origin", branch], { + cwd, + env: null, + timeoutMs: 180_000, + }); + const lines: string[] = []; + if (!pushRun.ok) { + lines.push(`git push failed: ${formatSpawnFailure(pushRun.error)}`); + writeFileSync(file, lines.join("\n"), "utf-8"); + return { + content: `publish: push failed\nLog: ${file}`, + meta: { success: false }, + }; + } + lines.push(`$ git push -u origin ${branch}`); + lines.push(pushRun.value.stdout); + lines.push(pushRun.value.stderr); + + const token = process.env.GITEA_TOKEN ?? (await cfgGet(nerveRoot, "GITEA_TOKEN")); + if (token === null || token.length === 0) { + lines.push("missing GITEA_TOKEN"); + writeFileSync(file, lines.join("\n"), "utf-8"); + return { + content: `publish: push ok but no GITEA_TOKEN for PR API\nLog: ${file}`, + meta: { success: false }, + }; + } + + const base = repoInfo?.defaultBranch ?? "main"; + const title = `fix: issue #${parsed.number}`; + const body = [`Automated PR from solve-issue workflow.`, `Issue: https://${parsed.host}/${parsed.owner}/${parsed.repo}/issues/${parsed.number}`].join( + "\n", + ); + const apiUrl = `https://${parsed.host}/api/v1/repos/${parsed.owner}/${parsed.repo}/pulls`; + + let prOk = false; + let prBody = ""; + try { + const res = await fetch(apiUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `token ${token}`, + }, + body: JSON.stringify({ + title, + body, + head: branch, + base, + }), + }); + prBody = await res.text(); + prOk = res.ok; + lines.push(`POST ${apiUrl} -> ${res.status}`); + lines.push(prBody.slice(0, 4000)); + } catch (e) { + lines.push(`fetch error: ${e instanceof Error ? e.message : String(e)}`); + } + + writeFileSync(file, lines.join("\n"), "utf-8"); + + if (!prOk) { + return { + content: `publish: push ok but PR API failed\nLog: ${file}`, + meta: { success: false }, + }; + } + + return { + content: `publish: pushed ${branch} and opened PR\nLog: ${file}`, + meta: { success: true }, + }; + }; +} diff --git a/workflows/solve-issue/roles/read-issue/index.ts b/workflows/solve-issue/roles/read-issue/index.ts new file mode 100644 index 0000000..c856e46 --- /dev/null +++ b/workflows/solve-issue/roles/read-issue/index.ts @@ -0,0 +1,20 @@ +import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; +import { createHermesRole } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; +import { readIssuePrompt } from "./prompt.js"; + +export const readIssueMetaSchema = z.object({ + ready: z.boolean().describe("true if issue content was fetched and markers are present"), +}); +export type ReadIssueMeta = z.infer; + +export type BuildReadIssueDeps = { + provider: LlmProvider; +}; + +export function buildReadIssueRole({ provider }: BuildReadIssueDeps) { + return createHermesRole({ + prompt: async (threadId) => readIssuePrompt({ threadId }), + extract: { provider, schema: readIssueMetaSchema }, + }); +} diff --git a/workflows/solve-issue/roles/read-issue/prompt.ts b/workflows/solve-issue/roles/read-issue/prompt.ts new file mode 100644 index 0000000..9a6ea15 --- /dev/null +++ b/workflows/solve-issue/roles/read-issue/prompt.ts @@ -0,0 +1,34 @@ +export function readIssuePrompt({ threadId }: { threadId: string }): string { + return `You are the **read-issue** agent. You fetch Gitea issue content via the \`tea\` CLI. + +Read the workflow thread start prompt for the issue URL (same run): \`nerve thread show ${threadId}\` + +## Steps + +1. From the **initial user prompt** (issue URL), extract **host**, **owner**, **repo**, and **issue number**. Supported shape: + \`https://///issues/\` + +2. Run: + \`tea issue show --repo / --comments\` + (Add \`--json\` if helpful for parsing.) + +3. In your reply, include **structured issue text**: title, body, labels, and each comment (author + body + time). + +4. You **must** emit this marker block **exactly** (fill in real values): +\`\`\` +---SOLVE_ISSUE_PARSE--- +host: +owner: +repo: +number: +--- +\`\`\` + +5. End with JSON meta (verbatim block): +\`\`\`json +{ "ready": true } +\`\`\` +Use \`{ "ready": false }\` if you could not fetch or parse the issue. + +**ready=true** only if the issue was fetched successfully and the marker block is correct.`; +} diff --git a/workflows/solve-issue/roles/review/index.ts b/workflows/solve-issue/roles/review/index.ts new file mode 100644 index 0000000..dbe6900 --- /dev/null +++ b/workflows/solve-issue/roles/review/index.ts @@ -0,0 +1,21 @@ +import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; +import { createHermesRole } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; +import { reviewPrompt } from "./prompt.js"; + +export const reviewMetaSchema = z.object({ + approved: z.boolean().describe("true if diff is clean and ready for tests"), +}); +export type ReviewMeta = z.infer; + +export type BuildReviewDeps = { + provider: LlmProvider; + nerveRoot: string; +}; + +export function buildReviewRole({ provider, nerveRoot }: BuildReviewDeps) { + return createHermesRole({ + prompt: async (threadId) => reviewPrompt({ threadId, nerveRoot }), + extract: { provider, schema: reviewMetaSchema }, + }); +} diff --git a/workflows/solve-issue/roles/review/prompt.ts b/workflows/solve-issue/roles/review/prompt.ts new file mode 100644 index 0000000..25d108c --- /dev/null +++ b/workflows/solve-issue/roles/review/prompt.ts @@ -0,0 +1,35 @@ +export function reviewPrompt({ threadId, nerveRoot }: { threadId: string; nerveRoot: string }): string { + return `You are a **code reviewer** (Hermes). You run after implement and before test. + +Read Nerve workspace conventions: \`cat ${nerveRoot}/CONVENTIONS.md\` + +Read workflow context: \`nerve thread show ${threadId}\` + +Find **repo path** from \`---SOLVE_ISSUE_REPO--- path:\` in the thread (prepare step). \`cd\` there before any git commands. + +## Static analysis + +Run: + +1. \`cd && git diff --stat\` +2. \`cd && git diff\` +3. \`cd && git status --short\` + +## Checklist + +Reject (**approved: false**) if you find: + +- Garbage files, secrets/credentials, unrelated changes +- Violations of CONVENTIONS.md (e.g. \`interface\` vs \`type\`, dynamic \`import()\`) + +Approve (**approved: true**) if the diff is clean and focused. + +End with: +\`\`\`json +{ "approved": true } +\`\`\` +or +\`\`\`json +{ "approved": false } +\`\`\``; +} diff --git a/workflows/solve-issue/roles/test/index.ts b/workflows/solve-issue/roles/test/index.ts new file mode 100644 index 0000000..aa95899 --- /dev/null +++ b/workflows/solve-issue/roles/test/index.ts @@ -0,0 +1,20 @@ +import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; +import { createHermesRole } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; +import { testPrompt } from "./prompt.js"; + +export const testMetaSchema = z.object({ + passed: z.boolean().describe("true if all test commands passed"), +}); +export type TestMeta = z.infer; + +export type BuildTestDeps = { + provider: LlmProvider; +}; + +export function buildTestRole({ provider }: BuildTestDeps) { + return createHermesRole({ + prompt: async (threadId) => testPrompt({ threadId }), + extract: { provider, schema: testMetaSchema }, + }); +} diff --git a/workflows/solve-issue/roles/test/prompt.ts b/workflows/solve-issue/roles/test/prompt.ts new file mode 100644 index 0000000..bb4256d --- /dev/null +++ b/workflows/solve-issue/roles/test/prompt.ts @@ -0,0 +1,21 @@ +export function testPrompt({ threadId }: { threadId: string }): string { + return `You are the **test** agent (Hermes). You execute automated tests for the change. + +Read workflow context: \`nerve thread show ${threadId}\` + +Find **repo path** from \`---SOLVE_ISSUE_REPO--- path:\` in the thread. + +From the **plan** step output, locate **Test commands** (explicit shell commands). Run each command with cwd = repo path, in order. + +If the plan lists **no** test commands, try **pnpm test**, then **npm test** if pnpm is unavailable; if neither applies, explain skip. + +Collect stdout/stderr snippets on failure. + +End with JSON only: +\`\`\`json +{ "passed": true } +\`\`\` +or \`{ "passed": false }\` + +**passed=true** only if every executed command exited 0 (or skip was justified with no failing command).`; +} diff --git a/workflows/solve-issue/tsconfig.json b/workflows/solve-issue/tsconfig.json new file mode 100644 index 0000000..fc00159 --- /dev/null +++ b/workflows/solve-issue/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"] +}