From c71212a0ceebc8b5fa88d205010c6c8908ef1223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sat, 2 May 2026 13:44:22 +0000 Subject: [PATCH] refactor: sense triggers shell command directly, remove workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SenseTrigger is now { command: string } — no workflow coupling - Restart gateway via direct systemctl command instead of workflow - Remove workflows/ directory and workflow config from nerve.yaml --- nerve.yaml | 5 - senses/hermes-gateway-health/src/index.ts | 15 +-- 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/restart-gateway/src/index.ts | 109 ----------------- 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 ------- 26 files changed, 5 insertions(+), 1593 deletions(-) delete mode 100644 workflows/develop-sense/index.ts delete mode 100644 workflows/develop-workflow/index.ts delete mode 100644 workflows/extract-knowledge/build.ts delete mode 100644 workflows/extract-knowledge/index.ts delete mode 100644 workflows/extract-knowledge/lib/knowledge-queue.ts delete mode 100644 workflows/extract-knowledge/lib/workdir.ts delete mode 100644 workflows/extract-knowledge/moderator.ts delete mode 100644 workflows/extract-knowledge/roles/answerer.ts delete mode 100644 workflows/extract-knowledge/roles/explorer.ts delete mode 100644 workflows/extract-knowledge/roles/questioner.ts delete mode 100644 workflows/restart-gateway/src/index.ts delete mode 100644 workflows/solve-issue/build.ts delete mode 100644 workflows/solve-issue/index.ts delete mode 100644 workflows/solve-issue/lib/provider.ts delete mode 100644 workflows/solve-issue/lib/repo-context.ts delete mode 100644 workflows/solve-issue/moderator.ts delete mode 100644 workflows/solve-issue/roles/committer.ts delete mode 100644 workflows/solve-issue/roles/implement.ts delete mode 100644 workflows/solve-issue/roles/plan.ts delete mode 100644 workflows/solve-issue/roles/prepare.ts delete mode 100644 workflows/solve-issue/roles/publish.ts delete mode 100644 workflows/solve-issue/roles/read-issue.ts delete mode 100644 workflows/solve-issue/roles/review.ts delete mode 100644 workflows/solve-issue/roles/test.ts diff --git a/nerve.yaml b/nerve.yaml index a230e85..1983d2b 100644 --- a/nerve.yaml +++ b/nerve.yaml @@ -10,8 +10,3 @@ senses: interval: 2m throttle: 30s timeout: 30s - -workflows: - restart-gateway: - concurrency: 1 - overflow: drop diff --git a/senses/hermes-gateway-health/src/index.ts b/senses/hermes-gateway-health/src/index.ts index bc570b8..87bffd6 100644 --- a/senses/hermes-gateway-health/src/index.ts +++ b/senses/hermes-gateway-health/src/index.ts @@ -7,7 +7,7 @@ const HTTP_TIMEOUT_MS = Math.min(23_000, EXEC_TIMEOUT_MS - 2000); const HTTP_ERROR_MAX_LEN = 256; -/** How many consecutive failures before triggering a restart workflow. */ +/** How many consecutive failures before triggering a restart. */ const FAILURE_THRESHOLD = 3; type SenseState = { @@ -337,7 +337,7 @@ export async function compute(prevState: SenseState) { const cooldown = prevState.restartCooldownMs; const cooldownElapsed = now - lastRestartTs >= cooldown; - // --- trigger restart workflow? --- + // --- trigger restart? --- const shouldRestart = consecutiveFailures >= FAILURE_THRESHOLD && cooldownElapsed; @@ -361,14 +361,9 @@ export async function compute(prevState: SenseState) { consecutiveFailures, }; - const workflow = shouldRestart - ? { - name: "restart-gateway", - maxRounds: 3, - prompt: `Hermes gateway is down (${consecutiveFailures} consecutive failures). Last HTTP error: "${httpError}". systemd active+running: ${systemdActiveRunning}, process alive: ${psOk}. Restart the gateway and verify it comes back.`, - dryRun: false, - } + const trigger = shouldRestart + ? { command: "systemctl --user restart hermes-gateway" } : null; - return { state: nextState, signal, workflow }; + return { state: nextState, signal, trigger }; } diff --git a/workflows/develop-sense/index.ts b/workflows/develop-sense/index.ts deleted file mode 100644 index 4d3720f..0000000 --- a/workflows/develop-sense/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 83ef09a..0000000 --- a/workflows/develop-workflow/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index d507a31..0000000 --- a/workflows/extract-knowledge/build.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 335eecb..0000000 --- a/workflows/extract-knowledge/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 899314a..0000000 --- a/workflows/extract-knowledge/lib/knowledge-queue.ts +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index 08f56f5..0000000 --- a/workflows/extract-knowledge/lib/workdir.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 8e04715..0000000 --- a/workflows/extract-knowledge/moderator.ts +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index ff13f4f..0000000 --- a/workflows/extract-knowledge/roles/answerer.ts +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index a2f9a8d..0000000 --- a/workflows/extract-knowledge/roles/explorer.ts +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 59662f0..0000000 --- a/workflows/extract-knowledge/roles/questioner.ts +++ /dev/null @@ -1,108 +0,0 @@ -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/restart-gateway/src/index.ts b/workflows/restart-gateway/src/index.ts deleted file mode 100644 index 9311007..0000000 --- a/workflows/restart-gateway/src/index.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { execFile } from "node:child_process"; -import type { RoleResult, ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core"; -import { END } from "@uncaged/nerve-core"; - -const EXEC_TIMEOUT_MS = 30_000; -const VERIFY_DELAY_MS = 5_000; - -type ExecResult = { - exitCode: number; - stdout: string; - stderr: string; -}; - -function exec(file: string, args: string[]): Promise { - return new Promise((resolve) => { - execFile( - file, - args, - { - encoding: "utf8", - timeout: EXEC_TIMEOUT_MS, - maxBuffer: 4 * 1024 * 1024, - } as Parameters[2], - (err, stdout, stderr) => { - const exitCode = - err && typeof (err as NodeJS.ErrnoException).status === "number" - ? (err as NodeJS.ErrnoException & { status: number }).status - : err ? -1 : 0; - resolve({ - exitCode, - stdout: String(stdout ?? ""), - stderr: String(stderr ?? ""), - }); - }, - ); - }); -} - -function sleep(ms: number): Promise { - return new Promise((r) => setTimeout(r, ms)); -} - -type RestartMeta = { - action: string; - exitCode: number; - output: string; -}; - -type VerifyMeta = { - alive: boolean; - activeState: string; - subState: string; -}; - -async function restarter(_ctx: ThreadContext): Promise> { - const r = await exec("systemctl", ["--user", "restart", "hermes-gateway"]); - return { - content: r.exitCode === 0 - ? "Gateway restart command succeeded." - : `Gateway restart failed (exit ${r.exitCode}): ${r.stderr.trim()}`, - meta: { - action: "systemctl --user restart hermes-gateway", - exitCode: r.exitCode, - output: `${r.stdout}\n${r.stderr}`.trim().slice(0, 500), - }, - }; -} - -async function verifier(_ctx: ThreadContext): Promise> { - // Wait a few seconds for the service to come up - await sleep(VERIFY_DELAY_MS); - - const r = await exec("systemctl", [ - "--user", - "--no-pager", - "show", - "hermes-gateway", - "-p", "ActiveState", - "-p", "SubState", - ]); - - let activeState = "unknown"; - let subState = "unknown"; - for (const line of r.stdout.split("\n")) { - const t = line.trim(); - if (t.startsWith("ActiveState=")) activeState = t.slice("ActiveState=".length); - if (t.startsWith("SubState=")) subState = t.slice("SubState=".length); - } - - const alive = activeState === "active" && subState === "running"; - - return { - content: alive - ? `Gateway recovered: ${activeState} (${subState}).` - : `Gateway still down: ${activeState} (${subState}). May need manual intervention.`, - meta: { alive, activeState, subState }, - }; -} - -export const workflow: WorkflowDefinition & Record<"verifier", VerifyMeta>> = { - name: "restart-gateway", - roles: { restarter, verifier }, - moderator(ctx) { - // Round 0: restart. Round 1: verify. Done. - if (ctx.steps.length === 0) return "restarter"; - if (ctx.steps.length === 1) return "verifier"; - return END; - }, -}; diff --git a/workflows/solve-issue/build.ts b/workflows/solve-issue/build.ts deleted file mode 100644 index 1b879bc..0000000 --- a/workflows/solve-issue/build.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index fed8314..0000000 --- a/workflows/solve-issue/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index a1bde83..0000000 --- a/workflows/solve-issue/lib/provider.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index c0928a1..0000000 --- a/workflows/solve-issue/lib/repo-context.ts +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 14809b4..0000000 --- a/workflows/solve-issue/moderator.ts +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index 7507469..0000000 --- a/workflows/solve-issue/roles/committer.ts +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 59efb05..0000000 --- a/workflows/solve-issue/roles/implement.ts +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index dfe2fa3..0000000 --- a/workflows/solve-issue/roles/plan.ts +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index ffc8f90..0000000 --- a/workflows/solve-issue/roles/prepare.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 17c99f3..0000000 --- a/workflows/solve-issue/roles/publish.ts +++ /dev/null @@ -1,110 +0,0 @@ -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 deleted file mode 100644 index 312fb13..0000000 --- a/workflows/solve-issue/roles/read-issue.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index ff2b4bf..0000000 --- a/workflows/solve-issue/roles/review.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 8e8eadd..0000000 --- a/workflows/solve-issue/roles/test.ts +++ /dev/null @@ -1,40 +0,0 @@ -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, - ); -}