chore(workflow): auto-generated commit

This commit is contained in:
小橘 2026-04-28 16:02:16 +00:00
parent 57c740cdde
commit 95587260f6
25 changed files with 872 additions and 0 deletions

View File

@ -36,3 +36,6 @@ workflows:
gitea-issue-solver: gitea-issue-solver:
concurrency: 1 concurrency: 1
overflow: drop overflow: drop
solve-issue:
concurrency: 1
overflow: drop

22
pnpm-lock.yaml generated
View File

@ -137,6 +137,28 @@ importers:
specifier: ^5.7.0 specifier: ^5.7.0
version: 5.9.3 version: 5.9.3
workflows/solve-issue:
dependencies:
'@uncaged/nerve-core':
specifier: link:../../../repos/nerve/packages/core
version: link:../../../repos/nerve/packages/core
'@uncaged/nerve-workflow-utils':
specifier: link:../../../repos/nerve/packages/workflow-utils
version: link:../../../repos/nerve/packages/workflow-utils
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.19.17
esbuild:
specifier: ^0.27.0
version: 0.27.7
typescript:
specifier: ^5.7.0
version: 5.9.3
workflows/workflow-generator: workflows/workflow-generator:
dependencies: dependencies:
'@uncaged/nerve-core': '@uncaged/nerve-core':

1
workflows/solve-issue/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
dist/

View File

@ -0,0 +1,32 @@
import type { WorkflowDefinition } from "@uncaged/nerve-core";
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { moderator } from "./moderator.js";
import type { WorkflowMeta } from "./moderator.js";
import { buildImplementRole } from "./roles/implement/index.js";
import { buildPlanRole } from "./roles/plan/index.js";
import { buildPrepareRole } from "./roles/prepare/index.js";
import { buildPublishRole } from "./roles/publish/index.js";
import { buildReadIssueRole } from "./roles/read-issue/index.js";
import { buildReviewRole } from "./roles/review/index.js";
import { buildTestRole } from "./roles/test/index.js";
export type BuildSolveIssueDeps = {
nerveRoot: string;
provider: LlmProvider;
};
export function buildSolveIssue({ nerveRoot, provider }: BuildSolveIssueDeps): WorkflowDefinition<WorkflowMeta> {
return {
name: "solve-issue",
roles: {
"read-issue": buildReadIssueRole({ provider }),
prepare: buildPrepareRole({ provider }),
plan: buildPlanRole({ provider, nerveRoot }),
implement: buildImplementRole({ provider, nerveRoot }),
review: buildReviewRole({ provider, nerveRoot }),
test: buildTestRole({ provider }),
publish: buildPublishRole({ nerveRoot }),
},
moderator,
};
}

View File

@ -0,0 +1,16 @@
import { join } from "node:path";
import { buildSolveIssue } from "./build.js";
import { resolveDashScopeProvider } from "./lib/provider.js";
const HOME = process.env.HOME ?? "/home/azureuser";
const NERVE_ROOT = join(HOME, ".uncaged-nerve");
const provider = await resolveDashScopeProvider(NERVE_ROOT);
if (provider === null) {
throw new Error("Set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or cfg get equivalents)");
}
const workflow = buildSolveIssue({ nerveRoot: NERVE_ROOT, provider });
export default workflow;

View File

@ -0,0 +1,21 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { spawnSafe } from "@uncaged/nerve-workflow-utils";
export async function cfgGet(nerveRoot: string, key: string): Promise<string | null> {
const result = await spawnSafe("cfg", ["get", key], { cwd: nerveRoot, env: null, timeoutMs: 10_000 });
if (!result.ok) {
return null;
}
const value = result.value.stdout.trim();
return value.length > 0 ? value : null;
}
export async function resolveDashScopeProvider(nerveRoot: string): Promise<LlmProvider | null> {
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 };
}

View File

@ -0,0 +1,110 @@
import { join } from "node:path";
import type { WorkflowMessage } from "@uncaged/nerve-core";
export type SolveIssueParse = {
host: string;
owner: string;
repo: string;
number: number;
};
export type SolveIssueRepo = {
path: string;
defaultBranch: string;
packageManager: string;
};
const HOME = process.env.HOME ?? "/home/azureuser";
function extractMarkedSection(text: string, marker: string): Record<string, string> | 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<string, string> = {};
for (const line of m[1].split("\n")) {
const kv = line.match(/^([a-zA-Z]+):\s*(.+)$/);
if (kv !== null) {
rec[kv[1]] = kv[2].trim();
}
}
return Object.keys(rec).length > 0 ? rec : null;
}
export function parseSolveIssueParse(text: string): SolveIssueParse | null {
const rec = extractMarkedSection(text, "SOLVE_ISSUE_PARSE");
if (rec === null) {
return null;
}
const host = rec.host ?? "";
const owner = rec.owner ?? "";
const repo = rec.repo ?? "";
const num = Number(rec.number ?? "");
if (host.length === 0 || owner.length === 0 || repo.length === 0 || !Number.isFinite(num) || num <= 0) {
return null;
}
return { host, owner, repo, number: num };
}
export function parseSolveIssueRepo(text: string): SolveIssueRepo | null {
const rec = extractMarkedSection(text, "SOLVE_ISSUE_REPO");
if (rec === null) {
return null;
}
const path = rec.path ?? "";
if (path.length === 0) {
return null;
}
return {
path,
defaultBranch: rec.defaultBranch ?? "main",
packageManager: rec.packageManager ?? "pnpm",
};
}
/** Prefer explicit prepare marker; else ~/Code/<owner>/<repo> from read-issue parse block. */
export function resolveRepoCwd(messages: WorkflowMessage[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "prepare") {
const repo = parseSolveIssueRepo(messages[i].content);
if (repo !== null) {
return repo.path;
}
}
}
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "read-issue") {
const parsed = parseSolveIssueParse(messages[i].content);
if (parsed !== null) {
return join(HOME, "Code", parsed.owner, parsed.repo);
}
}
}
return null;
}
export function lastParseFromMessages(messages: WorkflowMessage[]): SolveIssueParse | null {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "read-issue") {
const parsed = parseSolveIssueParse(messages[i].content);
if (parsed !== null) {
return parsed;
}
}
}
return null;
}
export function lastRepoFromMessages(messages: WorkflowMessage[]): SolveIssueRepo | null {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "prepare") {
const repo = parseSolveIssueRepo(messages[i].content);
if (repo !== null) {
return repo;
}
}
}
return null;
}

View File

@ -0,0 +1,11 @@
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
export function formatSpawnFailure(error: SpawnError): string {
if (error.kind === "spawn_failed") {
return `spawn_failed: ${error.message}`;
}
if (error.kind === "timeout") {
return `timeout: stdout=${error.stdout.slice(0, 200)} stderr=${error.stderr.slice(0, 200)}`;
}
return `non_zero_exit(${error.exitCode}): stderr=${error.stderr.slice(0, 400)}`;
}

View File

@ -0,0 +1,7 @@
import type { StartStep } from "@uncaged/nerve-core";
/** Runtime may include threadId before types catch up in published nerve-core. */
export function threadIdFromStart(start: StartStep): string {
const m = start.meta as { threadId?: string };
return typeof m.threadId === "string" && m.threadId.length > 0 ? m.threadId : "unknown";
}

View File

@ -0,0 +1,89 @@
import { END } from "@uncaged/nerve-core";
import type { Moderator } from "@uncaged/nerve-core";
import type { ReadIssueMeta } from "./roles/read-issue/index.js";
import type { PrepareMeta } from "./roles/prepare/index.js";
import type { PlanMeta } from "./roles/plan/index.js";
import type { ImplementMeta } from "./roles/implement/index.js";
import type { ReviewMeta } from "./roles/review/index.js";
import type { TestMeta } from "./roles/test/index.js";
import type { PublishMeta } from "./roles/publish/index.js";
export type WorkflowMeta = {
"read-issue": ReadIssueMeta;
prepare: PrepareMeta;
plan: PlanMeta;
implement: ImplementMeta;
review: ReviewMeta;
test: TestMeta;
publish: PublishMeta;
};
const MAX_IMPLEMENT_ROUNDS = 20;
const MAX_TOTAL_REJECTIONS = 10;
function implementRounds(steps: { role: string }[]): number {
return steps.filter((s) => s.role === "implement").length;
}
function totalRejections(steps: { role: string; meta: unknown }[]): number {
return steps.filter((s) => {
if (s.role === "review") return !(s.meta as Record<string, boolean>).approved;
if (s.role === "test") return !(s.meta as Record<string, boolean>).passed;
if (s.role === "publish") return !(s.meta as Record<string, boolean>).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<WorkflowMeta> = (context) => {
if (context.steps.length === 0) {
return "read-issue";
}
const last = context.steps[context.steps.length - 1];
if (last.role === "read-issue") {
return last.meta.ready ? "prepare" : END;
}
if (last.role === "prepare") {
return last.meta.ready ? "plan" : END;
}
if (last.role === "plan") {
return last.meta.ready ? "implement" : END;
}
if (last.role === "implement") {
if (last.meta.done) {
return "review";
}
return canRetryImplement(context.steps) ? "implement" : END;
}
if (last.role === "review") {
if (last.meta.approved) {
return "test";
}
return canRetryImplement(context.steps) ? "implement" : END;
}
if (last.role === "test") {
if (last.meta.passed) {
return "publish";
}
return canRetryImplement(context.steps) ? "implement" : END;
}
if (last.role === "publish") {
if (last.meta.success) {
return END;
}
return canRetryImplement(context.steps) ? "implement" : END;
}
return END;
};

View File

@ -0,0 +1,19 @@
{
"name": "solve-issue-workflow",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external"
},
"dependencies": {
"@uncaged/nerve-core": "latest",
"@uncaged/nerve-workflow-utils": "latest",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.27.0",
"typescript": "^5.7.0"
}
}

View File

@ -0,0 +1,64 @@
import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
import { cursorAgent, isDryRun, llmExtract } from "@uncaged/nerve-workflow-utils";
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
import { resolveRepoCwd } from "../../lib/repo-context.js";
import { threadIdFromStart } from "../../lib/start-meta.js";
import { formatSpawnFailure } from "../../lib/spawn-utils.js";
import { buildImplementPrompt } from "./prompt.js";
export const implementMetaSchema = z.object({
done: z.boolean().describe("true when changes are complete and build passes this round"),
});
export type ImplementMeta = z.infer<typeof implementMetaSchema>;
export type BuildImplementDeps = {
provider: LlmProvider;
nerveRoot: string;
};
export function buildImplementRole({ provider, nerveRoot }: BuildImplementDeps): Role<ImplementMeta> {
return async (start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<ImplementMeta>> => {
const cwd = resolveRepoCwd(messages);
if (cwd === null) {
return {
content: "implement cannot run: missing repo path in thread markers",
meta: { done: false },
};
}
const dry = isDryRun(start);
const prompt = buildImplementPrompt({ threadId: threadIdFromStart(start), nerveRoot });
const run = await cursorAgent({
prompt,
mode: "default",
model: "auto",
cwd,
env: null,
timeoutMs: 300_000,
dryRun: dry,
});
if (!run.ok) {
return {
content: `implement cursor-agent failed: ${formatSpawnFailure(run.error)}`,
meta: { done: false },
};
}
const metaR = await llmExtract({
text: run.value,
schema: implementMetaSchema,
provider,
dryRun: dry,
});
if (!metaR.ok) {
return {
content: `${run.value}\n\n[meta extract failed]`,
meta: { done: false },
};
}
return { content: run.value, meta: metaR.value };
};
}

View File

@ -0,0 +1,24 @@
export function buildImplementPrompt({ threadId, nerveRoot }: { threadId: string; nerveRoot: string }): string {
return `You are the **implement** agent. You apply code changes for the issue.
Read workflow context (plan, reviewer/test feedback): \`nerve thread show ${threadId}\`
Read Nerve workspace conventions: \`cat ${nerveRoot}/CONVENTIONS.md\`
Your cwd is the target repository.
## Requirements
1. Create a branch: \`fix/issue-<number>-<short-slug>\` (use \`feat/\` if the issue is clearly a feature). Use a slug from the issue title (lowercase, hyphens).
2. Implement the planned changes; address reviewer/tester feedback from the thread if any.
3. Run the project **build** (\`pnpm build\`, \`npm run build\`, etc.) and fix issues until build passes.
4. Multi-step: if you cannot finish this round, explain why and set **done** to false.
Then close with JSON:
\`\`\`json
{ "done": true }
\`\`\`
or \`{ "done": false }\` matching whether implementation is complete.
**done=true** only when changes are complete **and** build passes in this round.`;
}

View File

@ -0,0 +1,64 @@
import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
import { cursorAgent, isDryRun, llmExtract } from "@uncaged/nerve-workflow-utils";
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
import { resolveRepoCwd } from "../../lib/repo-context.js";
import { threadIdFromStart } from "../../lib/start-meta.js";
import { formatSpawnFailure } from "../../lib/spawn-utils.js";
import { buildPlanPrompt } from "./prompt.js";
export const planMetaSchema = z.object({
ready: z.boolean().describe("true if plan is clear and actionable"),
});
export type PlanMeta = z.infer<typeof planMetaSchema>;
export type BuildPlanDeps = {
provider: LlmProvider;
nerveRoot: string;
};
export function buildPlanRole({ provider, nerveRoot }: BuildPlanDeps): Role<PlanMeta> {
return async (start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<PlanMeta>> => {
const cwd = resolveRepoCwd(messages);
if (cwd === null) {
return {
content: "plan cannot run: missing ---SOLVE_ISSUE_REPO--- or ---SOLVE_ISSUE_PARSE--- in thread",
meta: { ready: false },
};
}
const dry = isDryRun(start);
const prompt = buildPlanPrompt({ threadId: threadIdFromStart(start), nerveRoot });
const run = await cursorAgent({
prompt,
mode: "ask",
model: "auto",
cwd,
env: null,
timeoutMs: 300_000,
dryRun: dry,
});
if (!run.ok) {
return {
content: `plan cursor-agent failed: ${formatSpawnFailure(run.error)}`,
meta: { ready: false },
};
}
const metaR = await llmExtract({
text: run.value,
schema: planMetaSchema,
provider,
dryRun: dry,
});
if (!metaR.ok) {
return {
content: `${run.value}\n\n[meta extract failed]`,
meta: { ready: false },
};
}
return { content: run.value, meta: metaR.value };
};
}

View File

@ -0,0 +1,27 @@
export function buildPlanPrompt({ threadId, nerveRoot }: { threadId: string; nerveRoot: string }): string {
return `You are the **plan** agent (analysis only — ask mode). You produce an implementation plan for fixing the issue.
Read workflow context: \`nerve thread show ${threadId}\`
Read Nerve workspace conventions (coding rules for agents): \`cat ${nerveRoot}/CONVENTIONS.md\`
In the **target repository** (your cwd), skim relevant files and read \`CONVENTIONS.md\` **if it exists** there.
## Output
Write an implementation plan in **markdown** with:
1. Problem understanding
2. Change strategy
3. Target files (paths)
4. **Test commands** to run (explicit shell commands, e.g. \`pnpm test\`, \`pnpm vitest run\`)
5. Risks
End your reply with a JSON code block (meta signal):
\`\`\`json
{ "ready": true }
\`\`\`
Use \`{ "ready": false }\` if the plan cannot be made actionable.
**ready=true** only when the plan is clear and actionable.`;
}

View File

@ -0,0 +1,20 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createHermesRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
import { preparePrompt } from "./prompt.js";
export const prepareMetaSchema = z.object({
ready: z.boolean().describe("true if repo is ready and baseline build ok"),
});
export type PrepareMeta = z.infer<typeof prepareMetaSchema>;
export type BuildPrepareDeps = {
provider: LlmProvider;
};
export function buildPrepareRole({ provider }: BuildPrepareDeps) {
return createHermesRole<PrepareMeta>({
prompt: async (threadId) => preparePrompt({ threadId }),
extract: { provider, schema: prepareMetaSchema },
});
}

View File

@ -0,0 +1,40 @@
export function preparePrompt({ threadId }: { threadId: string }): string {
return `You are the **prepare** agent. You clone or update the target repository and verify a clean baseline build.
Read prior messages / thread for issue markers: \`nerve thread show ${threadId}\`
## Goal
Find **owner** and **repo** from \`---SOLVE_ISSUE_PARSE---\` in the thread (from read-issue).
Let \`REPOPATH=$HOME/Code/<owner>/<repo>\` (expand \`$HOME\`).
## Steps
1. \`mkdir -p "$HOME/Code/<owner>"\`
2. If \`REPOPATH/.git\` is missing: \`git clone https://<host>/<owner>/<repo>.git "$REPOPATH"\` (use host from markers; adjust scheme if needed).
Else: \`cd "$REPOPATH" && git fetch --all && git pull --ff-only\`
3. \`cd "$REPOPATH"\` — ensure working tree clean: if \`git status --porcelain\` is non-empty, \`git stash push -u -m "solve-issue stash"\`
4. Detect default branch (\`main\` or \`master\`) and \`git checkout <default>\`
5. Detect package manager: \`pnpm-lock.yaml\` → pnpm, \`yarn.lock\` → yarn, \`package-lock.json\` → npm; run install (\`pnpm install --no-frozen-lockfile\` / \`npm ci\` or \`npm install\` / \`yarn\` as appropriate).
6. If \`package.json\` has a \`build\` script, run the build (\`pnpm build\`, etc.) and fix nothing — only verify baseline passes.
## Required marker block
Emit **exactly**:
\`\`\`
---SOLVE_ISSUE_REPO---
path: <absolute path to REPOPATH>
defaultBranch: <main or master>
packageManager: <pnpm|npm|yarn>
---
\`\`\`
End with:
\`\`\`json
{ "ready": true }
\`\`\`
or \`{ "ready": false }\` if clone/install/build baseline failed.
**ready=true** only when the repo exists at \`path\`, is clean, dependencies installed, and baseline build succeeded (or no build script).`;
}

View File

@ -0,0 +1,138 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
import { isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils";
import { cfgGet } from "../../lib/provider.js";
import { lastParseFromMessages, lastRepoFromMessages, resolveRepoCwd } from "../../lib/repo-context.js";
import { formatSpawnFailure } from "../../lib/spawn-utils.js";
export type PublishMeta = {
success: boolean;
};
export type BuildPublishDeps = {
nerveRoot: string;
};
function logPath(nerveRoot: string): string {
return join(nerveRoot, "logs", `solve-issue-publish-${Date.now()}.log`);
}
export function buildPublishRole({ nerveRoot }: BuildPublishDeps): Role<PublishMeta> {
return async (start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<PublishMeta>> => {
const dry = isDryRun(start);
const file = logPath(nerveRoot);
mkdirSync(join(file, ".."), { recursive: true });
const cwd = resolveRepoCwd(messages);
const parsed = lastParseFromMessages(messages);
const repoInfo = lastRepoFromMessages(messages);
if (dry) {
const msg = "[dry-run] publish skipped (no git push / PR)";
writeFileSync(file, `${msg}\n`, "utf-8");
return {
content: `[dry-run] publish skipped — log: ${file}`,
meta: { success: true },
};
}
if (cwd === null || parsed === null) {
writeFileSync(file, "missing cwd or SOLVE_ISSUE_PARSE\n", "utf-8");
return {
content: `publish failed: missing repo path or issue markers\nLog: ${file}`,
meta: { success: false },
};
}
const branchRun = await spawnSafe("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
cwd,
env: null,
timeoutMs: 15_000,
});
if (!branchRun.ok) {
writeFileSync(file, formatSpawnFailure(branchRun.error), "utf-8");
return {
content: `publish failed: cannot read branch — ${formatSpawnFailure(branchRun.error)}`,
meta: { success: false },
};
}
const branch = branchRun.value.stdout.trim();
if (branch.length === 0) {
return { content: "publish failed: empty branch name", meta: { success: false } };
}
const pushRun = await spawnSafe("git", ["push", "-u", "origin", branch], {
cwd,
env: null,
timeoutMs: 180_000,
});
const lines: string[] = [];
if (!pushRun.ok) {
lines.push(`git push failed: ${formatSpawnFailure(pushRun.error)}`);
writeFileSync(file, lines.join("\n"), "utf-8");
return {
content: `publish: push failed\nLog: ${file}`,
meta: { success: false },
};
}
lines.push(`$ git push -u origin ${branch}`);
lines.push(pushRun.value.stdout);
lines.push(pushRun.value.stderr);
const token = process.env.GITEA_TOKEN ?? (await cfgGet(nerveRoot, "GITEA_TOKEN"));
if (token === null || token.length === 0) {
lines.push("missing GITEA_TOKEN");
writeFileSync(file, lines.join("\n"), "utf-8");
return {
content: `publish: push ok but no GITEA_TOKEN for PR API\nLog: ${file}`,
meta: { success: false },
};
}
const base = repoInfo?.defaultBranch ?? "main";
const title = `fix: issue #${parsed.number}`;
const body = [`Automated PR from solve-issue workflow.`, `Issue: https://${parsed.host}/${parsed.owner}/${parsed.repo}/issues/${parsed.number}`].join(
"\n",
);
const apiUrl = `https://${parsed.host}/api/v1/repos/${parsed.owner}/${parsed.repo}/pulls`;
let prOk = false;
let prBody = "";
try {
const res = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `token ${token}`,
},
body: JSON.stringify({
title,
body,
head: branch,
base,
}),
});
prBody = await res.text();
prOk = res.ok;
lines.push(`POST ${apiUrl} -> ${res.status}`);
lines.push(prBody.slice(0, 4000));
} catch (e) {
lines.push(`fetch error: ${e instanceof Error ? e.message : String(e)}`);
}
writeFileSync(file, lines.join("\n"), "utf-8");
if (!prOk) {
return {
content: `publish: push ok but PR API failed\nLog: ${file}`,
meta: { success: false },
};
}
return {
content: `publish: pushed ${branch} and opened PR\nLog: ${file}`,
meta: { success: true },
};
};
}

View File

@ -0,0 +1,20 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createHermesRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
import { readIssuePrompt } from "./prompt.js";
export const readIssueMetaSchema = z.object({
ready: z.boolean().describe("true if issue content was fetched and markers are present"),
});
export type ReadIssueMeta = z.infer<typeof readIssueMetaSchema>;
export type BuildReadIssueDeps = {
provider: LlmProvider;
};
export function buildReadIssueRole({ provider }: BuildReadIssueDeps) {
return createHermesRole<ReadIssueMeta>({
prompt: async (threadId) => readIssuePrompt({ threadId }),
extract: { provider, schema: readIssueMetaSchema },
});
}

View File

@ -0,0 +1,34 @@
export function readIssuePrompt({ threadId }: { threadId: string }): string {
return `You are the **read-issue** agent. You fetch Gitea issue content via the \`tea\` CLI.
Read the workflow thread start prompt for the issue URL (same run): \`nerve thread show ${threadId}\`
## Steps
1. From the **initial user prompt** (issue URL), extract **host**, **owner**, **repo**, and **issue number**. Supported shape:
\`https://<host>/<owner>/<repo>/issues/<number>\`
2. Run:
\`tea issue show <number> --repo <owner>/<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: <host>
owner: <owner>
repo: <repo>
number: <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.`;
}

View File

@ -0,0 +1,21 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createHermesRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
import { reviewPrompt } from "./prompt.js";
export const reviewMetaSchema = z.object({
approved: z.boolean().describe("true if diff is clean and ready for tests"),
});
export type ReviewMeta = z.infer<typeof reviewMetaSchema>;
export type BuildReviewDeps = {
provider: LlmProvider;
nerveRoot: string;
};
export function buildReviewRole({ provider, nerveRoot }: BuildReviewDeps) {
return createHermesRole<ReviewMeta>({
prompt: async (threadId) => reviewPrompt({ threadId, nerveRoot }),
extract: { provider, schema: reviewMetaSchema },
});
}

View File

@ -0,0 +1,35 @@
export function reviewPrompt({ threadId, nerveRoot }: { threadId: string; nerveRoot: string }): string {
return `You are a **code reviewer** (Hermes). You run after implement and before test.
Read Nerve workspace conventions: \`cat ${nerveRoot}/CONVENTIONS.md\`
Read workflow context: \`nerve thread show ${threadId}\`
Find **repo path** from \`---SOLVE_ISSUE_REPO--- path:\` in the thread (prepare step). \`cd\` there before any git commands.
## Static analysis
Run:
1. \`cd <repo-path> && git diff --stat\`
2. \`cd <repo-path> && git diff\`
3. \`cd <repo-path> && 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 }
\`\`\``;
}

View File

@ -0,0 +1,20 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createHermesRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
import { testPrompt } from "./prompt.js";
export const testMetaSchema = z.object({
passed: z.boolean().describe("true if all test commands passed"),
});
export type TestMeta = z.infer<typeof testMetaSchema>;
export type BuildTestDeps = {
provider: LlmProvider;
};
export function buildTestRole({ provider }: BuildTestDeps) {
return createHermesRole<TestMeta>({
prompt: async (threadId) => testPrompt({ threadId }),
extract: { provider, schema: testMetaSchema },
});
}

View File

@ -0,0 +1,21 @@
export function testPrompt({ threadId }: { threadId: string }): string {
return `You are the **test** agent (Hermes). You execute automated tests for the change.
Read workflow context: \`nerve thread show ${threadId}\`
Find **repo path** from \`---SOLVE_ISSUE_REPO--- path:\` in the thread.
From the **plan** step output, locate **Test commands** (explicit shell commands). Run each command with cwd = repo path, in order.
If the plan lists **no** test commands, try **pnpm test**, then **npm test** if pnpm is unavailable; if neither applies, explain skip.
Collect stdout/stderr snippets on failure.
End with JSON only:
\`\`\`json
{ "passed": true }
\`\`\`
or \`{ "passed": false }\`
**passed=true** only if every executed command exited 0 (or skip was justified with no failing command).`;
}

View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["./**/*.ts"]
}