From a4625a4559351e9718762453fce0319e8d021506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sat, 2 May 2026 13:55:27 +0000 Subject: [PATCH] fix: restore CLI-triggered workflows, only remove restart-gateway The previous commit incorrectly deleted all workflows. Only restart-gateway should be removed (replaced by direct shell trigger). Other workflows (solve-issue, extract-knowledge, develop-sense, develop-workflow) are CLI-triggered and independent of sense coupling. --- nerve.yaml | 14 +++ workflows/develop-sense/index.ts | 33 ++++++ workflows/develop-workflow/index.ts | 34 ++++++ workflows/extract-knowledge/build.ts | 33 ++++++ workflows/extract-knowledge/index.ts | 30 +++++ .../extract-knowledge/lib/knowledge-queue.ts | 74 ++++++++++++ workflows/extract-knowledge/lib/workdir.ts | 21 ++++ workflows/extract-knowledge/moderator.ts | 84 +++++++++++++ workflows/extract-knowledge/roles/answerer.ts | 102 ++++++++++++++++ workflows/extract-knowledge/roles/explorer.ts | 93 +++++++++++++++ .../extract-knowledge/roles/questioner.ts | 108 +++++++++++++++++ workflows/solve-issue/build.ts | 43 +++++++ workflows/solve-issue/index.ts | 37 ++++++ workflows/solve-issue/lib/provider.ts | 26 +++++ workflows/solve-issue/lib/repo-context.ts | 86 ++++++++++++++ workflows/solve-issue/moderator.ts | 99 ++++++++++++++++ workflows/solve-issue/roles/committer.ts | 57 +++++++++ workflows/solve-issue/roles/implement.ts | 86 ++++++++++++++ workflows/solve-issue/roles/plan.ts | 88 ++++++++++++++ workflows/solve-issue/roles/prepare.ts | 73 ++++++++++++ workflows/solve-issue/roles/publish.ts | 110 ++++++++++++++++++ workflows/solve-issue/roles/read-issue.ts | 53 +++++++++ workflows/solve-issue/roles/review.ts | 59 ++++++++++ workflows/solve-issue/roles/test.ts | 40 +++++++ 24 files changed, 1483 insertions(+) create mode 100644 workflows/develop-sense/index.ts create mode 100644 workflows/develop-workflow/index.ts create mode 100644 workflows/extract-knowledge/build.ts create mode 100644 workflows/extract-knowledge/index.ts create mode 100644 workflows/extract-knowledge/lib/knowledge-queue.ts create mode 100644 workflows/extract-knowledge/lib/workdir.ts create mode 100644 workflows/extract-knowledge/moderator.ts create mode 100644 workflows/extract-knowledge/roles/answerer.ts create mode 100644 workflows/extract-knowledge/roles/explorer.ts create mode 100644 workflows/extract-knowledge/roles/questioner.ts 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/moderator.ts create mode 100644 workflows/solve-issue/roles/committer.ts create mode 100644 workflows/solve-issue/roles/implement.ts create mode 100644 workflows/solve-issue/roles/plan.ts create mode 100644 workflows/solve-issue/roles/prepare.ts create mode 100644 workflows/solve-issue/roles/publish.ts create mode 100644 workflows/solve-issue/roles/read-issue.ts create mode 100644 workflows/solve-issue/roles/review.ts create mode 100644 workflows/solve-issue/roles/test.ts diff --git a/nerve.yaml b/nerve.yaml index 1983d2b..e76d7a0 100644 --- a/nerve.yaml +++ b/nerve.yaml @@ -10,3 +10,17 @@ senses: interval: 2m throttle: 30s timeout: 30s + +workflows: + develop-sense: + concurrency: 1 + overflow: queue + develop-workflow: + concurrency: 1 + overflow: queue + solve-issue: + concurrency: 1 + overflow: queue + extract-knowledge: + concurrency: 1 + overflow: queue diff --git a/workflows/develop-sense/index.ts b/workflows/develop-sense/index.ts new file mode 100644 index 0000000..4d3720f --- /dev/null +++ b/workflows/develop-sense/index.ts @@ -0,0 +1,33 @@ +import { join } from "node:path"; +import { createCursorAdapter, cursorAdapter } from "@uncaged/nerve-adapter-cursor"; +import { hermesAdapter } from "@uncaged/nerve-adapter-hermes"; +import { createDevelopSenseWorkflow } from "@uncaged/nerve-workflow-meta"; + +const HOME = process.env.HOME ?? "/home/azureuser"; +const NERVE_ROOT = join(HOME, ".uncaged-nerve"); + +const apiKey = process.env.DASHSCOPE_API_KEY; +const baseUrl = process.env.DASHSCOPE_BASE_URL; +const model = process.env.DASHSCOPE_MODEL ?? "qwen-plus"; +if (!apiKey || !baseUrl) { + throw new Error("Set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL"); +} + +const CURSOR_TIMEOUT_MS = 300_000; + +const workflow = createDevelopSenseWorkflow({ + defaultAdapter: hermesAdapter, + adapters: { + planner: createCursorAdapter({ + type: "cursor", + mode: "ask", + model: "auto", + timeout: CURSOR_TIMEOUT_MS, + }), + coder: cursorAdapter, + }, + extract: { provider: { apiKey, baseUrl, model } }, + cwd: NERVE_ROOT, +}); + +export default workflow; diff --git a/workflows/develop-workflow/index.ts b/workflows/develop-workflow/index.ts new file mode 100644 index 0000000..83ef09a --- /dev/null +++ b/workflows/develop-workflow/index.ts @@ -0,0 +1,34 @@ +import { join } from "node:path"; +import { createCursorAdapter, cursorAdapter } from "@uncaged/nerve-adapter-cursor"; +import { hermesAdapter } from "@uncaged/nerve-adapter-hermes"; +import { createDevelopWorkflowWorkflow } from "@uncaged/nerve-workflow-meta"; + +const HOME = process.env.HOME ?? "/home/azureuser"; +const NERVE_ROOT = join(HOME, ".uncaged-nerve"); + +const apiKey = process.env.DASHSCOPE_API_KEY; +const baseUrl = process.env.DASHSCOPE_BASE_URL; +const model = process.env.DASHSCOPE_MODEL ?? "qwen-plus"; + +if (!apiKey || !baseUrl) { + throw new Error("Set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL"); +} + +const CURSOR_TIMEOUT_MS = 300_000; + +const workflow = createDevelopWorkflowWorkflow({ + defaultAdapter: hermesAdapter, + adapters: { + planner: createCursorAdapter({ + type: "cursor", + mode: "ask", + model: "auto", + timeout: CURSOR_TIMEOUT_MS, + }), + coder: cursorAdapter, + }, + extract: { provider: { apiKey, baseUrl, model } }, + nerveRoot: NERVE_ROOT, +}); + +export default workflow; diff --git a/workflows/extract-knowledge/build.ts b/workflows/extract-knowledge/build.ts new file mode 100644 index 0000000..d507a31 --- /dev/null +++ b/workflows/extract-knowledge/build.ts @@ -0,0 +1,33 @@ +import type { AgentFn, WorkflowDefinition } from "@uncaged/nerve-core"; +import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; +import { createLlmAdapter } from "@uncaged/nerve-workflow-utils"; + +import { moderator } from "./moderator.js"; +import type { WorkflowMeta } from "./moderator.js"; +import { createAnswererRole } from "./roles/answerer.js"; +import { createExplorerRole } from "./roles/explorer.js"; +import { createQuestionerRole } from "./roles/questioner.js"; + +export type CreateKnowledgeExtractionDeps = { + defaultAdapter: AgentFn; + adapters?: Partial>; + extract: LlmExtractorConfig; +}; + +export function createKnowledgeExtractionWorkflow({ + defaultAdapter, + adapters, + extract, +}: CreateKnowledgeExtractionDeps): WorkflowDefinition { + const a = (role: keyof WorkflowMeta) => adapters?.[role] ?? defaultAdapter; + const llmAdapter = createLlmAdapter(extract.provider); + return { + name: "extract-knowledge", + roles: { + questioner: createQuestionerRole(adapters?.questioner ?? llmAdapter, { extract }), + answerer: createAnswererRole(adapters?.answerer ?? llmAdapter, { extract }), + explorer: createExplorerRole(a("explorer"), { extract }), + }, + moderator, + }; +} diff --git a/workflows/extract-knowledge/index.ts b/workflows/extract-knowledge/index.ts new file mode 100644 index 0000000..335eecb --- /dev/null +++ b/workflows/extract-knowledge/index.ts @@ -0,0 +1,30 @@ +import { join } from "node:path"; +import { createCursorAdapter } from "@uncaged/nerve-adapter-cursor"; +import { hermesAdapter } from "@uncaged/nerve-adapter-hermes"; +import { createKnowledgeExtractionWorkflow } from "./build.js"; +import { resolveDashScopeProvider } from "../solve-issue/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 CURSOR_TIMEOUT_MS = 300_000; + +const workflow = createKnowledgeExtractionWorkflow({ + defaultAdapter: hermesAdapter, + adapters: { + explorer: createCursorAdapter({ + type: "cursor", + model: "claude-sonnet-4", + timeout: CURSOR_TIMEOUT_MS, + }), + }, + extract: { provider }, +}); + +export default workflow; diff --git a/workflows/extract-knowledge/lib/knowledge-queue.ts b/workflows/extract-knowledge/lib/knowledge-queue.ts new file mode 100644 index 0000000..899314a --- /dev/null +++ b/workflows/extract-knowledge/lib/knowledge-queue.ts @@ -0,0 +1,74 @@ +import type { Dirent } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; + +import type { StartStep, WorkflowMessage } from "@uncaged/nerve-core"; + +import type { ExplorerMeta } from "../roles/explorer.js"; +import type { QuestionerMeta } from "../roles/questioner.js"; + +async function walkMarkdownFiles(rootDir: string, base: string): Promise { + const out: string[] = []; + let entries: Dirent[]; + try { + entries = (await readdir(rootDir, { withFileTypes: true })) as Dirent[]; + } catch { + return out; + } + for (const e of entries) { + const name = e.name; + const rel = base ? `${base}/${name}` : name; + const full = join(rootDir, name); + if (e.isDirectory()) { + out.push(...(await walkMarkdownFiles(full, rel))); + } else if (e.isFile() && name.endsWith(".md")) { + out.push(rel.replace(/\\/g, "/")); + } + } + return out; +} + +/** Enumerate all markdown files under `.knowledge/` as repo-relative paths; seed line first if present. */ +export async function bootstrapKnowledgeQueue(cwd: string, startContent: string): Promise { + const knowledgeDir = join(cwd, ".knowledge"); + const relFiles = await walkMarkdownFiles(knowledgeDir, ""); + const paths = relFiles.map((f) => `.knowledge/${f}`); + const seed = startContent.trim().split(/\r?\n/u)[0]?.trim() ?? ""; + if (paths.length === 0 && seed.length > 0) { + return [seed]; + } + if (seed.length > 0 && paths.includes(seed)) { + return [seed, ...paths.filter((p) => p !== seed)]; + } + if (seed.length > 0 && !paths.includes(seed)) { + return [seed, ...paths]; + } + return [...paths].sort(); +} + +function lastIndexOfRole(messages: WorkflowMessage[], role: string): number { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === role) return i; + } + return -1; +} + +/** Next queue for questioner: bootstrap, or continue after answerer / explorer. */ +export async function resolveQueueForQuestioner( + start: StartStep, + messages: WorkflowMessage[], + cwd: string, +): Promise { + const lastQi = lastIndexOfRole(messages, "questioner"); + if (lastQi === -1) { + return bootstrapKnowledgeQueue(cwd, start.content); + } + const qMeta = messages[lastQi].meta as QuestionerMeta; + const tail = messages.slice(lastQi + 1); + const explorerMsg = tail.find((m) => m.role === "explorer"); + if (explorerMsg) { + const eMeta = explorerMsg.meta as ExplorerMeta; + return [...qMeta.remaining_queue, ...eMeta.new_cards]; + } + return qMeta.remaining_queue; +} diff --git a/workflows/extract-knowledge/lib/workdir.ts b/workflows/extract-knowledge/lib/workdir.ts new file mode 100644 index 0000000..08f56f5 --- /dev/null +++ b/workflows/extract-knowledge/lib/workdir.ts @@ -0,0 +1,21 @@ +import type { StartStep } from "@uncaged/nerve-core"; + +type StartMetaWithWorkdir = StartStep["meta"] & { workdir?: string | null }; + +/** + * Resolve the target repo working directory. + * Priority: start.meta.workdir → prompt second line (if absolute path) → cwd. + */ +export function resolveWorkdir(start: StartStep): string { + const m = start.meta as StartMetaWithWorkdir; + if (m.workdir) return m.workdir; + + // Allow prompt to carry workdir on the second line: "seed\n/abs/path" + const lines = start.content.split(/\r?\n/); + if (lines.length >= 2) { + const candidate = lines[1]!.trim(); + if (candidate.startsWith("/")) return candidate; + } + + return process.cwd(); +} diff --git a/workflows/extract-knowledge/moderator.ts b/workflows/extract-knowledge/moderator.ts new file mode 100644 index 0000000..8e04715 --- /dev/null +++ b/workflows/extract-knowledge/moderator.ts @@ -0,0 +1,84 @@ +import { END } from "@uncaged/nerve-core"; +import type { Moderator, ThreadContext } from "@uncaged/nerve-core"; + +import type { AnswererMeta } from "./roles/answerer.js"; +import type { ExplorerMeta } from "./roles/explorer.js"; +import type { QuestionerMeta } from "./roles/questioner.js"; + +export type WorkflowMeta = { + questioner: QuestionerMeta; + answerer: AnswererMeta; + explorer: ExplorerMeta; +}; + +type Steps = ThreadContext["steps"]; + +function lastQuestionerRemaining(steps: Steps): QuestionerMeta | undefined { + for (let i = steps.length - 1; i >= 0; i--) { + const s = steps[i]; + if (s.role === "questioner") return s.meta; + } + return undefined; +} + +/** End when the last two explorer invocations both added no new cards (issue #266 stagnation rule). */ +function lastTwoExplorerRunsBothEmpty(steps: Steps): boolean { + const explorerSteps = steps.filter((s) => s.role === "explorer"); + if (explorerSteps.length < 2) return false; + const e1 = explorerSteps[explorerSteps.length - 1].meta as ExplorerMeta; + const e2 = explorerSteps[explorerSteps.length - 2].meta as ExplorerMeta; + return e1.new_cards.length === 0 && e2.new_cards.length === 0; +} + +function queueAfterSkippedExplorer(steps: Steps): string[] { + const q = lastQuestionerRemaining(steps); + return q?.remaining_queue ?? []; +} + +function queueAfterExplorerStep(steps: Steps): string[] { + const last = steps[steps.length - 1]; + if (!last || last.role !== "explorer") return []; + const q = lastQuestionerRemaining(steps); + if (!q) return []; + const e = last.meta as ExplorerMeta; + return [...q.remaining_queue, ...e.new_cards]; +} + +export const moderator: Moderator = (context) => { + const { steps } = context; + + if (steps.length === 0) { + return "questioner"; + } + + const last = steps[steps.length - 1]; + + if (last.role === "questioner") { + return "answerer"; + } + + if (last.role === "answerer") { + const am = last.meta as AnswererMeta; + if (am.has_unanswered) { + return "explorer"; + } + const q = queueAfterSkippedExplorer(steps); + if (q.length === 0) { + return END; + } + return "questioner"; + } + + if (last.role === "explorer") { + if (lastTwoExplorerRunsBothEmpty(steps)) { + return END; + } + const q = queueAfterExplorerStep(steps); + if (q.length === 0) { + return END; + } + return "questioner"; + } + + return END; +}; diff --git a/workflows/extract-knowledge/roles/answerer.ts b/workflows/extract-knowledge/roles/answerer.ts new file mode 100644 index 0000000..ff13f4f --- /dev/null +++ b/workflows/extract-knowledge/roles/answerer.ts @@ -0,0 +1,102 @@ +import type { AgentFn, Role, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core"; +import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; +import { createRole, nerveCommandEnv, spawnSafe } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; + +import { resolveWorkdir } from "../lib/workdir.js"; + +import type { QuestionerMeta } from "./questioner.js"; + +export const answererMetaSchema = z.object({ + results: z.array( + z.object({ + id: z.string(), + found: z.boolean(), + source: z.string(), + note: z.string(), + }), + ), + has_unanswered: z.boolean(), +}); + +export type AnswererMeta = z.infer; + +export type CreateAnswererRoleDeps = { + extract: LlmExtractorConfig; +}; + +function lastQuestionerMeta(messages: WorkflowMessage[]): QuestionerMeta | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "questioner") { + return messages[i].meta as QuestionerMeta; + } + } + return undefined; +} + +export async function answererPrompt(ctx: ThreadContext): Promise { + const messages = ctx.steps as unknown as WorkflowMessage[]; + const cwd = resolveWorkdir(ctx.start); + const qm = lastQuestionerMeta(messages); + if (!qm || qm.questions.length === 0) { + throw new Error("answerer: prompt invoked without questioner questions — wrapped role should short-circuit"); + } + + const blocks: string[] = []; + for (const q of qm.questions) { + if ((ctx.start.meta as Record).dryRun) { + blocks.push(`### ${q.id}\n[dryRun] skipped nerve knowledge query\n`); + continue; + } + const res = await spawnSafe( + "nerve", + ["knowledge", "query", q.question], + { + cwd, + env: nerveCommandEnv(), + timeoutMs: 120_000, + dryRun: false, + abortSignal: null, + }, + ); + if (res.ok) { + blocks.push(`### ${q.id} (${q.domain})\nQuestion: ${q.question}\n---\n${res.value.stdout}\n`); + } else { + const err = res.error; + const detail = + err.kind === "non_zero_exit" + ? `exit ${err.exitCode}\n${err.stderr}` + : err.kind === "timeout" + ? `timeout\n${err.stderr}` + : err.kind === "spawn_failed" + ? err.message + : "aborted"; + blocks.push(`### ${q.id}\nnerve knowledge query failed: ${detail}\n`); + } + } + + return [ + "You are the **answerer**. You MUST NOT read repository source code — only the CLI retrieval excerpts below.", + "For each question id, decide whether the knowledge base already answers it.", + "Set found=true only when the excerpt supports a confident answer; otherwise found=false.", + "Set has_unanswered=true if any question remains unanswered by the knowledge base.", + "", + ...blocks, + ].join("\n"); +} + +export function createAnswererRole(adapter: AgentFn, { extract }: CreateAnswererRoleDeps): Role { + const inner = createRole(adapter, answererPrompt, answererMetaSchema, extract); + + return async (ctx: ThreadContext) => { + const messages = ctx.steps as unknown as WorkflowMessage[]; + const qm = lastQuestionerMeta(messages); + if (!qm || qm.questions.length === 0) { + return { + content: "answerer: no questions from questioner; skipping CLI lookup.", + meta: { results: [], has_unanswered: false }, + }; + } + return inner(ctx); + }; +} diff --git a/workflows/extract-knowledge/roles/explorer.ts b/workflows/extract-knowledge/roles/explorer.ts new file mode 100644 index 0000000..a2f9a8d --- /dev/null +++ b/workflows/extract-knowledge/roles/explorer.ts @@ -0,0 +1,93 @@ +import type { AgentFn, Role, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core"; +import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; +import { createRole } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; + +import { resolveWorkdir } from "../lib/workdir.js"; + +import type { AnswererMeta } from "./answerer.js"; +import type { QuestionerMeta } from "./questioner.js"; + +export const explorerMetaSchema = z.object({ + patches: z.array( + z.object({ + card: z.string(), + section: z.string(), + }), + ), + new_cards: z.array(z.string()), +}); + +export type ExplorerMeta = z.infer; + +export type CreateExplorerRoleDeps = { + extract: LlmExtractorConfig; +}; + +function lastMeta(messages: WorkflowMessage[], role: string): M | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === role) { + return messages[i].meta as M; + } + } + return undefined; +} + +export function explorerPrompt(ctx: ThreadContext): string { + const messages = ctx.steps as unknown as WorkflowMessage[]; + const threadId = ctx.start.meta.threadId; + const qm = lastMeta(messages, "questioner"); + const am = lastMeta(messages, "answerer"); + const cwd = resolveWorkdir(ctx.start); + + const unanswered = + am?.results.filter((r) => !r.found).map((r) => r.id) ?? []; + + return `You are the **explorer** in an extract-knowledge workflow. + +## Context + +- Thread: \`nerve thread ${threadId}\` +- Working directory (repo root for paths): ${cwd} +- Current knowledge card (questioner): ${qm?.card ?? "(unknown)"} + +## Unanswered question ids + +${JSON.stringify(unanswered)} + +Use the prior answerer results in the thread to map ids to full question text when you read messages above. + +## Task + +For each unanswered question, **read the codebase** as needed, then either: + +- Add a new markdown file under \`.knowledge/\`, or +- Patch an existing card (prefer updating the card listed above when appropriate). + +After any write or patch to \`.knowledge\`, run: + +\`\`\`bash +nerve knowledge sync +\`\`\` + +from this repo root (${cwd}), and fix failures until sync succeeds. + +## Output meta + +Report \`patches\` as { card, section } entries for cards you edited (section is a short heading or path hint). +Report \`new_cards\` as repo-relative paths for brand-new files you created (e.g. \`.knowledge/new-topic.md\`). + +Do not claim work you did not perform.`; +} + +export function createExplorerRole( + adapter: AgentFn, + { extract }: CreateExplorerRoleDeps, +): Role { + return createRole( + adapter, + async (ctx: ThreadContext) => explorerPrompt(ctx), + explorerMetaSchema, + extract, + ); +} diff --git a/workflows/extract-knowledge/roles/questioner.ts b/workflows/extract-knowledge/roles/questioner.ts new file mode 100644 index 0000000..59662f0 --- /dev/null +++ b/workflows/extract-knowledge/roles/questioner.ts @@ -0,0 +1,108 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import type { AgentFn, Role, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core"; +import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; +import { createRole } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; + +import { resolveQueueForQuestioner } from "../lib/knowledge-queue.js"; +import { resolveWorkdir } from "../lib/workdir.js"; + +const questionerExtractSchema = z.object({ + questions: z + .array( + z.object({ + id: z.string(), + question: z.string(), + domain: z.string(), + }), + ) + .length(5), +}); + +export type QuestionerMeta = { + /** Empty when no .knowledge cards and no work to do. */ + card: string; + questions: { id: string; question: string; domain: string }[]; + remaining_queue: string[]; +}; + +export type CreateQuestionerRoleDeps = { + extract: LlmExtractorConfig; +}; + +function questionerSystem(): string { + return `You are the **questioner** in an extract-knowledge workflow. + +Read the given markdown knowledge card. Propose exactly **five** technical questions that are **not** already answered or covered by that card. + +Rules: +- Questions must be concrete and technical. +- Each question needs a stable string id (e.g. q1, q2, q3, q4, q5), a short domain label (e.g. routing, storage), and the question text. +- Do not assume access to other files or tools — reason only from the card content shown.`; +} + +function questionerUser(card: string, cardBody: string, remainingHint: string[]): string { + return `Current card path: ${card} + +Remaining queue after this card (paths, may be empty): ${JSON.stringify(remainingHint)} + +--- Card content --- + +${cardBody}`; +} + +export async function questionerPrompt(ctx: ThreadContext): Promise { + const messages = ctx.steps as unknown as WorkflowMessage[]; + const cwd = resolveWorkdir(ctx.start); + const queue = await resolveQueueForQuestioner(ctx.start, messages, cwd); + if (queue.length === 0) { + throw new Error( + "questioner: prompt invoked with empty queue — wrapped role should short-circuit before LLM", + ); + } + const card = queue[0]!; + const remaining_queue = queue.slice(1); + let cardBody: string; + try { + cardBody = await readFile(join(cwd, card), "utf8"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(`questioner: failed to read ${card}: ${msg}`); + } + return `${questionerSystem()}\n\n${questionerUser(card, cardBody, remaining_queue)}`; +} + +export function createQuestionerRole(adapter: AgentFn, { extract }: CreateQuestionerRoleDeps): Role { + const inner = createRole(adapter, questionerPrompt, questionerExtractSchema, extract); + + return async (ctx: ThreadContext) => { + const messages = ctx.steps as unknown as WorkflowMessage[]; + const cwd = resolveWorkdir(ctx.start); + const queue = await resolveQueueForQuestioner(ctx.start, messages, cwd); + if (queue.length === 0) { + return { + content: + "questioner: no `.knowledge` markdown files found and no seed path in the trigger prompt; queue is empty.", + meta: { + card: "", + questions: [], + remaining_queue: [], + }, + }; + } + + const card = queue[0]!; + const remaining_queue = queue.slice(1); + const r = await inner(ctx); + return { + content: r.content, + meta: { + card, + questions: r.meta.questions, + remaining_queue, + }, + }; + }; +} diff --git a/workflows/solve-issue/build.ts b/workflows/solve-issue/build.ts new file mode 100644 index 0000000..1b879bc --- /dev/null +++ b/workflows/solve-issue/build.ts @@ -0,0 +1,43 @@ +import type { AgentFn, WorkflowDefinition } from "@uncaged/nerve-core"; +import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; + +import { moderator } from "./moderator.js"; +import type { WorkflowMeta } from "./moderator.js"; +import { createCommitterRole } from "./roles/committer.js"; +import { createImplementRole } from "./roles/implement.js"; +import { createPlanRole } from "./roles/plan.js"; +import { createPrepareRole } from "./roles/prepare.js"; +import { createPublishRole } from "./roles/publish.js"; +import { createReadIssueRole } from "./roles/read-issue.js"; +import { createReviewRole } from "./roles/review.js"; +import { createTestRole } from "./roles/test.js"; + +export type CreateSolveIssueDeps = { + defaultAdapter: AgentFn; + adapters?: Partial>; + nerveRoot: string; + extract: LlmExtractorConfig; +}; + +export function createSolveIssueWorkflow({ + defaultAdapter, + adapters, + nerveRoot, + extract, +}: CreateSolveIssueDeps): WorkflowDefinition { + const a = (role: keyof WorkflowMeta) => adapters?.[role] ?? defaultAdapter; + return { + name: "solve-issue", + roles: { + "read-issue": createReadIssueRole(a("read-issue"), extract), + prepare: createPrepareRole(a("prepare"), extract), + plan: createPlanRole(a("plan"), { extract, nerveRoot }), + implement: createImplementRole(a("implement"), { extract, nerveRoot }), + committer: createCommitterRole(a("committer"), extract), + review: createReviewRole(a("review"), extract, nerveRoot), + test: createTestRole(a("test"), extract), + publish: createPublishRole(a("publish"), { extract, nerveRoot }), + }, + moderator, + }; +} diff --git a/workflows/solve-issue/index.ts b/workflows/solve-issue/index.ts new file mode 100644 index 0000000..fed8314 --- /dev/null +++ b/workflows/solve-issue/index.ts @@ -0,0 +1,37 @@ +import { join } from "node:path"; +import { createCursorAdapter } from "@uncaged/nerve-adapter-cursor"; +import { hermesAdapter } from "@uncaged/nerve-adapter-hermes"; +import { createSolveIssueWorkflow } 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 CURSOR_TIMEOUT_MS = 300_000; + +const workflow = createSolveIssueWorkflow({ + defaultAdapter: hermesAdapter, + adapters: { + plan: createCursorAdapter({ + type: "cursor", + mode: "ask", + model: "auto", + timeout: CURSOR_TIMEOUT_MS, + }), + implement: createCursorAdapter({ + type: "cursor", + model: "auto", + timeout: CURSOR_TIMEOUT_MS, + }), + }, + nerveRoot: NERVE_ROOT, + extract: { 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..a1bde83 --- /dev/null +++ b/workflows/solve-issue/lib/provider.ts @@ -0,0 +1,26 @@ +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, + abortSignal: null, + }); + 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..c0928a1 --- /dev/null +++ b/workflows/solve-issue/lib/repo-context.ts @@ -0,0 +1,86 @@ +import { join } from "node:path"; +import type { RoleStep, WorkflowMessage } from "@uncaged/nerve-core"; + +type SolveIssueParse = { + host: string; + owner: string; + repo: string; + number: number; +}; + +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; +} + +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 }; +} + +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; +} diff --git a/workflows/solve-issue/moderator.ts b/workflows/solve-issue/moderator.ts new file mode 100644 index 0000000..14809b4 --- /dev/null +++ b/workflows/solve-issue/moderator.ts @@ -0,0 +1,99 @@ +import { END } from "@uncaged/nerve-core"; +import type { Moderator } from "@uncaged/nerve-core"; +import type { ReadIssueMeta } from "./roles/read-issue.js"; +import type { PrepareMeta } from "./roles/prepare.js"; +import type { PlanMeta } from "./roles/plan.js"; +import type { ImplementMeta } from "./roles/implement.js"; +import type { CommitterMeta } from "./roles/committer.js"; +import type { ReviewMeta } from "./roles/review.js"; +import type { TestMeta } from "./roles/test.js"; +import type { PublishMeta } from "./roles/publish.js"; + +export type WorkflowMeta = { + "read-issue": ReadIssueMeta; + prepare: PrepareMeta; + plan: PlanMeta; + implement: ImplementMeta; + committer: CommitterMeta; + 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 === "committer") return !(s.meta as Record).committed; + 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 "committer"; + } + return canRetryImplement(context.steps) ? "implement" : END; + } + + if (last.role === "committer") { + if (last.meta.committed) { + 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/roles/committer.ts b/workflows/solve-issue/roles/committer.ts new file mode 100644 index 0000000..7507469 --- /dev/null +++ b/workflows/solve-issue/roles/committer.ts @@ -0,0 +1,57 @@ +import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core"; +import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; +import { createRole, decorateRole, withDryRun, onFail } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; + +function committerPrompt({ threadId }: { threadId: string }): string { + return `You are the committer agent. The **implement** step finished with a passing build; your job is to branch, commit, and push. + +1. Read the workflow thread: \`nerve thread show ${threadId}\` — understand what was planned, implemented, and reviewed. +2. In the thread, locate \`---SOLVE_ISSUE_PARSE---\` and \`---SOLVE_ISSUE_REPO---\`. From them you need issue **number**, **title** (for the branch slug), repo **path**, and **defaultBranch**. +3. \`cd\` to the repo **path** from the markers. Optionally read \`CONVENTIONS.md\` in that repo root if present. +4. Run \`git rev-parse --abbrev-ref HEAD\` and compare with **defaultBranch** from the markers. Implement leaves changes uncommitted on the default branch — you should be on that branch with a dirty working tree. If you are not on the default branch, or the tree is clean when you expected changes, set **committed** to false and explain. +5. Run \`git status\`. If there is nothing to commit, set **committed** to false and explain. +6. Create a feature branch (do not commit directly on the default branch if it would mix unrelated work): + - Name: \`fix/-\` for fixes, or \`feat/-\` if the issue is clearly a feature. + - **slug**: lowercase, hyphens only, short (from issue title words). + - Example: \`git checkout -b fix/42-auth-timeout\` +7. \`git add -A\` +8. Write a **conventional commit** message describing what changed and why, using the thread context. +9. \`git commit -m ""\` — do NOT pass \`--author\`, use repo git config. +10. \`git push -u origin \` + +**committed=true** only if branch was created, commit succeeded, and **push** succeeded. + +End your reply with a JSON line: +\`\`\`json +{ "committed": true } +\`\`\` +or +\`\`\`json +{ "committed": false } +\`\`\``; +} + +export const committerMetaSchema = z.object({ + committed: z + .boolean() + .describe("true if branch created, changes committed, and pushed successfully"), +}); +export type CommitterMeta = z.infer; + +export function createCommitterRole( + adapter: AgentFn, + extract: LlmExtractorConfig, +): Role { + const inner = createRole( + adapter, + async (ctx: ThreadContext) => committerPrompt({ threadId: ctx.start.meta.threadId }), + committerMetaSchema, + extract, + ); + + return decorateRole(inner, [ + withDryRun({ label: "committer", meta: { committed: true } as CommitterMeta }), + onFail({ label: "committer", meta: { committed: false } as CommitterMeta }), + ]) as Role; +} diff --git a/workflows/solve-issue/roles/implement.ts b/workflows/solve-issue/roles/implement.ts new file mode 100644 index 0000000..59efb05 --- /dev/null +++ b/workflows/solve-issue/roles/implement.ts @@ -0,0 +1,86 @@ +import type { AgentFn, Role, RoleResult, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core"; +import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; +import { createRole } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; + +import { resolveRepoCwd } from "../lib/repo-context.js"; + +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. Implement the planned changes; address reviewer/tester feedback from the thread if any. +2. Run the project **build** (\`pnpm build\`, \`npm run build\`, etc.) and fix issues until build passes. +3. Multi-step: if you cannot finish this round, explain why and set **done** to false. + +Do **not** run \`git checkout -b\`, \`git add\`, \`git commit\`, or \`git push\`. **Never** create commits on any branch — branching and commits are handled by the **committer** step after you finish. + +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.`; +} + +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 CreateImplementRoleDeps = { + extract: LlmExtractorConfig; + nerveRoot: string; +}; + +export function createImplementRole( + adapter: AgentFn, + { extract, nerveRoot }: CreateImplementRoleDeps, +): Role { + return async (ctx: ThreadContext): Promise> => { + const messages = ctx.steps as unknown as WorkflowMessage[]; + const cwd = resolveRepoCwd(messages); + if (cwd === null) { + return { + content: "implement cannot run: missing repo path in thread markers", + meta: { done: false }, + }; + } + + const innerRole = createRole( + adapter, + async (innerCtx: ThreadContext) => + buildImplementPrompt({ + threadId: innerCtx.start.meta.threadId, + nerveRoot, + }), + implementMetaSchema, + extract, + ); + + const innerCtx: ThreadContext = { + ...ctx, + start: { + ...ctx.start, + meta: { ...ctx.start.meta, workdir: cwd }, + }, + }; + try { + return await innerRole(innerCtx); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return { + content: `implement failed: ${msg}`, + meta: { done: false }, + }; + } + }; +} diff --git a/workflows/solve-issue/roles/plan.ts b/workflows/solve-issue/roles/plan.ts new file mode 100644 index 0000000..dfe2fa3 --- /dev/null +++ b/workflows/solve-issue/roles/plan.ts @@ -0,0 +1,88 @@ +import type { AgentFn, Role, RoleResult, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core"; +import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; +import { createRole } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; + +import { resolveRepoCwd } from "../lib/repo-context.js"; + +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.`; +} + +export const planMetaSchema = z.object({ + ready: z.boolean().describe("true if plan is clear and actionable"), +}); +export type PlanMeta = z.infer; + +export type CreatePlanRoleDeps = { + extract: LlmExtractorConfig; + nerveRoot: string; +}; + +export function createPlanRole( + adapter: AgentFn, + { extract, nerveRoot }: CreatePlanRoleDeps, +): Role { + return async (ctx: ThreadContext): Promise> => { + const messages = ctx.steps as unknown as WorkflowMessage[]; + 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 innerRole = createRole( + adapter, + async (innerCtx: ThreadContext) => + buildPlanPrompt({ + threadId: innerCtx.start.meta.threadId, + nerveRoot, + }), + planMetaSchema, + extract, + ); + + const innerCtx: ThreadContext = { + ...ctx, + start: { + ...ctx.start, + meta: { ...ctx.start.meta, workdir: cwd }, + }, + }; + try { + return await innerRole(innerCtx); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return { + content: `plan failed: ${msg}`, + meta: { ready: false }, + }; + } + }; +} diff --git a/workflows/solve-issue/roles/prepare.ts b/workflows/solve-issue/roles/prepare.ts new file mode 100644 index 0000000..ffc8f90 --- /dev/null +++ b/workflows/solve-issue/roles/prepare.ts @@ -0,0 +1,73 @@ +import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core"; +import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; +import { createRole } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; + +function preparePrompt({ threadId }: { threadId: string }): string { + return `You are the **prepare** agent. You ensure the target repository is ready for work. + +Read prior messages / thread for issue markers: \`nerve thread show ${threadId}\` + +## Goal + +Find **owner**, **repo**, and **host** from \`---SOLVE_ISSUE_PARSE---\` in the thread (from read-issue). + +Check the **initial user prompt** (the trigger message) for a local repo path. The user may specify it like: +- \`--repo /path/to/repo\` +- \`repo: /path/to/repo\` +- or just mention an absolute path to the local clone + +## Steps + +### If a local path is provided in the trigger prompt: +1. Verify \`/.git\` exists — if not, fail with \`ready: false\` +2. \`cd "" && git fetch --all\` +3. 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 && git pull --ff-only\` +5. Use this path as REPOPATH + +### If no local path is provided: +1. Let \`REPOPATH=$HOME/Code//\` (expand \`$HOME\`) +2. \`mkdir -p "$HOME/Code/"\` +3. If \`REPOPATH/.git\` is missing: \`git clone https:////.git "$REPOPATH"\` + Else: \`cd "$REPOPATH" && git fetch --all && git pull --ff-only\` +4. Ensure working tree clean: if \`git status --porcelain\` is non-empty, \`git stash push -u -m "solve-issue stash"\` +5. Detect default branch and \`git checkout \` + +### Then (both paths): +6. 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\`). +7. 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 the repo is invalid, or 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).`; +} + +export const prepareMetaSchema = z.object({ + ready: z.boolean().describe("true if repo is ready and baseline build ok"), +}); +export type PrepareMeta = z.infer; + +export function createPrepareRole(adapter: AgentFn, extract: LlmExtractorConfig): Role { + return createRole( + adapter, + async (ctx: ThreadContext) => preparePrompt({ threadId: ctx.start.meta.threadId }), + prepareMetaSchema, + extract, + ); +} diff --git a/workflows/solve-issue/roles/publish.ts b/workflows/solve-issue/roles/publish.ts new file mode 100644 index 0000000..17c99f3 --- /dev/null +++ b/workflows/solve-issue/roles/publish.ts @@ -0,0 +1,110 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { AgentFn, Role, RoleResult, ThreadContext } from "@uncaged/nerve-core"; +import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; +import { createRole, isDryRun } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; + +function buildPublishPrompt({ threadId, nerveRoot }: { threadId: string; nerveRoot: string }): string { + return `You are the **publish** agent (Hermes). Test has passed. Open a pull request for the current branch using the **tea** CLI. + +## Context + +- Read the full workflow thread: \`nerve thread show ${threadId}\` +- Nerve workspace conventions (for tone/consistency, optional): \`cat ${nerveRoot}/CONVENTIONS.md\` + +## Repo and issue (from the thread) + +Find \`---SOLVE_ISSUE_PARSE---\` and \`---SOLVE_ISSUE_REPO---\` in prior messages. You need: +- \`path\` — clone checkout directory (this is your working copy) +- \`host\`, \`owner\`, \`repo\`, \`number\` for the issue +- \`defaultBranch\` (for PR base) from SOLVE_ISSUE_REPO + +**Issue link** for the Ref section: \`https://///issues/\` + +## Steps (in order) + +1. \`cd\` to the **repo \`path\`**. Run \`git rev-parse --abbrev-ref HEAD\` to get the current branch name. The **committer** step should already have pushed this branch; run \`git push -u origin \` only if the branch is not yet on the remote. +2. Choose a **PR title** that reflects the real change (not a generic \`fix: issue #N\`): derive it from the issue title, plan, and thread summary (keep it concise; Conventional Commits style is fine, e.g. \`fix(auth): handle session expiry\`). +3. Write a **PR body** in Markdown with exactly these sections, in this order, each with a \`##\` heading (fill with concise content based on the thread: plan, implement, review, test): + - **## What** — one short paragraph: what this PR does + - **## Why** — one short paragraph: motivation / issue + - **## Changes** — bullet list of notable changes + - **## Ref** — include one line \`Fixes #\` (same \`number\` from SOLVE_ISSUE_PARSE; closes/links the issue where supported) **and** the issue URL \`https://///issues/\` +4. Create the PR with **tea** (not curl/fetch to Gitea): + - \`tea pr create --repo / --base --head --title "" --body \` + - You may use a heredoc or a temp file for \`--body\` if the shell requires it; keep the four sections in the body. +5. Confirm the PR was created (tea prints a URL or PR number in typical setups). + +**success=true** only if both **push** and **tea** PR creation succeed. If any step fails, set **success=false** and say why. + +End your reply with a JSON line: +\`\`\`json +{ "success": true } +\`\`\` +or +\`\`\`json +{ "success": false } +\`\`\``; +} + +export const publishMetaSchema = z.object({ + success: z.boolean().describe("true if git push and tea pr create both succeeded"), +}); +export type PublishMeta = z.infer; + +export type CreatePublishRoleDeps = { + extract: LlmExtractorConfig; + nerveRoot: string; +}; + +function logPath(nerveRoot: string): string { + return join(nerveRoot, "logs", `solve-issue-publish-${Date.now()}.log`); +} + +export function createPublishRole( + adapter: AgentFn, + { extract, nerveRoot }: CreatePublishRoleDeps, +): Role { + const innerRole = createRole( + adapter, + async (ctx: ThreadContext) => + buildPublishPrompt({ threadId: ctx.start.meta.threadId, nerveRoot }), + publishMetaSchema, + extract, + ); + + return async (ctx: ThreadContext): Promise> => { + const file = logPath(nerveRoot); + mkdirSync(join(file, ".."), { recursive: true }); + + if (isDryRun(ctx.start)) { + 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 }, + }; + } + + const innerCtx: ThreadContext = { + ...ctx, + start: { + ...ctx.start, + meta: { ...ctx.start.meta, workdir: nerveRoot }, + }, + }; + + try { + return await innerRole(innerCtx); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const body = `publish failed: ${msg}\n`; + writeFileSync(file, body, "utf-8"); + return { + content: `publish failed: ${msg}\nLog: ${file}`, + meta: { success: false }, + }; + } + }; +} diff --git a/workflows/solve-issue/roles/read-issue.ts b/workflows/solve-issue/roles/read-issue.ts new file mode 100644 index 0000000..312fb13 --- /dev/null +++ b/workflows/solve-issue/roles/read-issue.ts @@ -0,0 +1,53 @@ +import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core"; +import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; +import { createRole } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; + +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.`; +} + +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 function createReadIssueRole(adapter: AgentFn, extract: LlmExtractorConfig): Role { + return createRole( + adapter, + async (ctx: ThreadContext) => readIssuePrompt({ threadId: ctx.start.meta.threadId }), + readIssueMetaSchema, + extract, + ); +} diff --git a/workflows/solve-issue/roles/review.ts b/workflows/solve-issue/roles/review.ts new file mode 100644 index 0000000..ff2b4bf --- /dev/null +++ b/workflows/solve-issue/roles/review.ts @@ -0,0 +1,59 @@ +import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core"; +import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; +import { createRole } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; + +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 } +\`\`\``; +} + +export const reviewMetaSchema = z.object({ + approved: z.boolean().describe("true if diff is clean and ready for tests"), +}); +export type ReviewMeta = z.infer; + +export function createReviewRole( + adapter: AgentFn, + extract: LlmExtractorConfig, + nerveRoot: string, +): Role { + return createRole( + adapter, + async (ctx: ThreadContext) => + reviewPrompt({ threadId: ctx.start.meta.threadId, nerveRoot }), + reviewMetaSchema, + extract, + ); +} diff --git a/workflows/solve-issue/roles/test.ts b/workflows/solve-issue/roles/test.ts new file mode 100644 index 0000000..8e8eadd --- /dev/null +++ b/workflows/solve-issue/roles/test.ts @@ -0,0 +1,40 @@ +import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core"; +import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; +import { createRole } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; + +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).`; +} + +export const testMetaSchema = z.object({ + passed: z.boolean().describe("true if all test commands passed"), +}); +export type TestMeta = z.infer; + +export function createTestRole(adapter: AgentFn, extract: LlmExtractorConfig): Role { + return createRole( + adapter, + async (ctx: ThreadContext) => testPrompt({ threadId: ctx.start.meta.threadId }), + testMetaSchema, + extract, + ); +}