chore(workflow): auto-generated commit
This commit is contained in:
parent
57c740cdde
commit
95587260f6
@ -36,3 +36,6 @@ workflows:
|
||||
gitea-issue-solver:
|
||||
concurrency: 1
|
||||
overflow: drop
|
||||
solve-issue:
|
||||
concurrency: 1
|
||||
overflow: drop
|
||||
|
||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@ -137,6 +137,28 @@ importers:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
|
||||
workflows/solve-issue:
|
||||
dependencies:
|
||||
'@uncaged/nerve-core':
|
||||
specifier: link:../../../repos/nerve/packages/core
|
||||
version: link:../../../repos/nerve/packages/core
|
||||
'@uncaged/nerve-workflow-utils':
|
||||
specifier: link:../../../repos/nerve/packages/workflow-utils
|
||||
version: link:../../../repos/nerve/packages/workflow-utils
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.17
|
||||
esbuild:
|
||||
specifier: ^0.27.0
|
||||
version: 0.27.7
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
|
||||
workflows/workflow-generator:
|
||||
dependencies:
|
||||
'@uncaged/nerve-core':
|
||||
|
||||
1
workflows/solve-issue/.gitignore
vendored
Normal file
1
workflows/solve-issue/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
dist/
|
||||
32
workflows/solve-issue/build.ts
Normal file
32
workflows/solve-issue/build.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
16
workflows/solve-issue/index.ts
Normal file
16
workflows/solve-issue/index.ts
Normal 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;
|
||||
21
workflows/solve-issue/lib/provider.ts
Normal file
21
workflows/solve-issue/lib/provider.ts
Normal 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 };
|
||||
}
|
||||
110
workflows/solve-issue/lib/repo-context.ts
Normal file
110
workflows/solve-issue/lib/repo-context.ts
Normal 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;
|
||||
}
|
||||
11
workflows/solve-issue/lib/spawn-utils.ts
Normal file
11
workflows/solve-issue/lib/spawn-utils.ts
Normal 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)}`;
|
||||
}
|
||||
7
workflows/solve-issue/lib/start-meta.ts
Normal file
7
workflows/solve-issue/lib/start-meta.ts
Normal 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";
|
||||
}
|
||||
89
workflows/solve-issue/moderator.ts
Normal file
89
workflows/solve-issue/moderator.ts
Normal 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;
|
||||
};
|
||||
19
workflows/solve-issue/package.json
Normal file
19
workflows/solve-issue/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
64
workflows/solve-issue/roles/implement/index.ts
Normal file
64
workflows/solve-issue/roles/implement/index.ts
Normal 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 };
|
||||
};
|
||||
}
|
||||
24
workflows/solve-issue/roles/implement/prompt.ts
Normal file
24
workflows/solve-issue/roles/implement/prompt.ts
Normal 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.`;
|
||||
}
|
||||
64
workflows/solve-issue/roles/plan/index.ts
Normal file
64
workflows/solve-issue/roles/plan/index.ts
Normal 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 };
|
||||
};
|
||||
}
|
||||
27
workflows/solve-issue/roles/plan/prompt.ts
Normal file
27
workflows/solve-issue/roles/plan/prompt.ts
Normal 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.`;
|
||||
}
|
||||
20
workflows/solve-issue/roles/prepare/index.ts
Normal file
20
workflows/solve-issue/roles/prepare/index.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
40
workflows/solve-issue/roles/prepare/prompt.ts
Normal file
40
workflows/solve-issue/roles/prepare/prompt.ts
Normal 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).`;
|
||||
}
|
||||
138
workflows/solve-issue/roles/publish/index.ts
Normal file
138
workflows/solve-issue/roles/publish/index.ts
Normal 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 },
|
||||
};
|
||||
};
|
||||
}
|
||||
20
workflows/solve-issue/roles/read-issue/index.ts
Normal file
20
workflows/solve-issue/roles/read-issue/index.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
34
workflows/solve-issue/roles/read-issue/prompt.ts
Normal file
34
workflows/solve-issue/roles/read-issue/prompt.ts
Normal 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.`;
|
||||
}
|
||||
21
workflows/solve-issue/roles/review/index.ts
Normal file
21
workflows/solve-issue/roles/review/index.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
35
workflows/solve-issue/roles/review/prompt.ts
Normal file
35
workflows/solve-issue/roles/review/prompt.ts
Normal 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 }
|
||||
\`\`\``;
|
||||
}
|
||||
20
workflows/solve-issue/roles/test/index.ts
Normal file
20
workflows/solve-issue/roles/test/index.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
21
workflows/solve-issue/roles/test/prompt.ts
Normal file
21
workflows/solve-issue/roles/test/prompt.ts
Normal 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).`;
|
||||
}
|
||||
13
workflows/solve-issue/tsconfig.json
Normal file
13
workflows/solve-issue/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user