Refactor committer into Hermes agent with branch/commit/push workflow
Add solve-issue committer after implement; replace develop-sense and develop-workflow script roles with createRole(hermesAdapter). Implement prompt no longer does git; publish prompt asks for meaningful PR titles. Refs #9 Made-with: Cursor
This commit is contained in:
parent
3a2b8a49a3
commit
c585e0d8a8
@ -12,21 +12,21 @@ import { reviewerPrompt } from "./roles/reviewer/prompt.js";
|
||||
import { reviewerMetaSchema } from "./roles/reviewer/index.js";
|
||||
import { testerPrompt } from "./roles/tester/prompt.js";
|
||||
import { testerMetaSchema } from "./roles/tester/index.js";
|
||||
import { buildCommitterRole } from "./roles/committer/index.js";
|
||||
import { buildWorkspaceCommitterRole } from "./roles/committer/index.js";
|
||||
import { moderator } from "./moderator.js";
|
||||
import type { SenseMeta } from "./moderator.js";
|
||||
|
||||
export type BuildSenseGeneratorDeps = {
|
||||
export type BuildDevelopSenseDeps = {
|
||||
extract: LlmExtractorConfig;
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
const CURSOR_TIMEOUT_MS = 300_000;
|
||||
|
||||
export function buildSenseGenerator({
|
||||
export function buildDevelopSenseWorkflow({
|
||||
extract,
|
||||
cwd,
|
||||
}: BuildSenseGeneratorDeps): WorkflowDefinition<SenseMeta> {
|
||||
}: BuildDevelopSenseDeps): WorkflowDefinition<SenseMeta> {
|
||||
const roles = {
|
||||
planner: createRole(
|
||||
createCursorAdapter({
|
||||
@ -59,7 +59,11 @@ export function buildSenseGenerator({
|
||||
testerMetaSchema,
|
||||
extract,
|
||||
),
|
||||
committer: buildCommitterRole({ nerveRoot: cwd }),
|
||||
committer: buildWorkspaceCommitterRole({
|
||||
extract,
|
||||
nerveRoot: cwd,
|
||||
workflowName: "develop-sense",
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { join } from "node:path";
|
||||
import { buildSenseGenerator } from "./build.js";
|
||||
import { buildDevelopSenseWorkflow } from "./build.js";
|
||||
|
||||
const HOME = process.env.HOME ?? "/home/azureuser";
|
||||
const NERVE_ROOT = join(HOME, ".uncaged-nerve");
|
||||
@ -11,7 +11,7 @@ if (!apiKey || !baseUrl) {
|
||||
throw new Error("Set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
|
||||
}
|
||||
|
||||
const workflow = buildSenseGenerator({
|
||||
const workflow = buildDevelopSenseWorkflow({
|
||||
extract: { provider: { apiKey, baseUrl, model } },
|
||||
cwd: NERVE_ROOT,
|
||||
});
|
||||
|
||||
@ -25,7 +25,7 @@ function totalRejections(steps: { role: string; meta: unknown }[]): number {
|
||||
return steps.filter((s) => {
|
||||
if (s.role === "reviewer") return !(s.meta as Record<string, boolean>).approved;
|
||||
if (s.role === "tester") return !(s.meta as Record<string, boolean>).passed;
|
||||
if (s.role === "committer") return !(s.meta as Record<string, boolean>).success;
|
||||
if (s.role === "committer") return !(s.meta as Record<string, boolean>).committed;
|
||||
return false;
|
||||
}).length;
|
||||
}
|
||||
@ -57,7 +57,7 @@ export const moderator: Moderator<SenseMeta> = (context) => {
|
||||
}
|
||||
|
||||
if (last.role === "committer") {
|
||||
if (last.meta.success) return END;
|
||||
if (last.meta.committed) return END;
|
||||
return canRetryCoder(context.steps) ? "coder" : END;
|
||||
}
|
||||
|
||||
|
||||
@ -1,91 +1,62 @@
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Role, RoleResult } from "@uncaged/nerve-core";
|
||||
import { isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils";
|
||||
import type { Role, RoleResult, StartStep } from "@uncaged/nerve-core";
|
||||
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole, isDryRun } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
|
||||
export type CommitterMeta = {
|
||||
success: boolean;
|
||||
};
|
||||
import { workspaceCommitterPrompt } from "./prompt.js";
|
||||
|
||||
export type BuildCommitterDeps = {
|
||||
export const committerMetaSchema = z.object({
|
||||
committed: z
|
||||
.boolean()
|
||||
.describe("true if branch created, changes committed, and pushed successfully"),
|
||||
});
|
||||
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
|
||||
|
||||
export type BuildWorkspaceCommitterDeps = {
|
||||
extract: LlmExtractorConfig;
|
||||
nerveRoot: string;
|
||||
workflowName: string;
|
||||
};
|
||||
|
||||
function logPath(nerveRoot: string): string {
|
||||
return join(nerveRoot, "logs", `committer-${Date.now()}.log`);
|
||||
}
|
||||
export function buildWorkspaceCommitterRole({
|
||||
extract,
|
||||
nerveRoot,
|
||||
workflowName,
|
||||
}: BuildWorkspaceCommitterDeps): Role<CommitterMeta> {
|
||||
const innerRole = createRole(
|
||||
hermesAdapter,
|
||||
async (start: StartStep) =>
|
||||
workspaceCommitterPrompt({
|
||||
threadId: start.meta.threadId,
|
||||
nerveRoot,
|
||||
workflowName,
|
||||
}),
|
||||
committerMetaSchema,
|
||||
extract,
|
||||
);
|
||||
|
||||
function writeLog(path: string, content: string): void {
|
||||
mkdirSync(join(path, ".."), { recursive: true });
|
||||
writeFileSync(path, content, "utf-8");
|
||||
}
|
||||
|
||||
export function buildCommitterRole({ nerveRoot }: BuildCommitterDeps): Role<CommitterMeta> {
|
||||
return async (start, _messages) => {
|
||||
const dry = isDryRun(start);
|
||||
const file = logPath(nerveRoot);
|
||||
|
||||
if (dry) {
|
||||
writeLog(file, "[dry-run] committer skipped\n");
|
||||
return async (start, _messages): Promise<RoleResult<CommitterMeta>> => {
|
||||
if (isDryRun(start)) {
|
||||
return {
|
||||
content: `[dry-run] committer skipped — log: ${file}`,
|
||||
meta: { success: true },
|
||||
} satisfies RoleResult<CommitterMeta>;
|
||||
content: "[dry-run] committer skipped (no git branch/commit/push)",
|
||||
meta: { committed: true },
|
||||
};
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
let success = true;
|
||||
const innerStart = {
|
||||
...start,
|
||||
meta: { ...start.meta, workdir: nerveRoot },
|
||||
} as StartStep;
|
||||
|
||||
const run = async (cmd: string, args: string[]): Promise<boolean> => {
|
||||
const r = await spawnSafe(cmd, args, {
|
||||
cwd: nerveRoot,
|
||||
env: null,
|
||||
timeoutMs: 60_000,
|
||||
dryRun: false,
|
||||
abortSignal: null,
|
||||
});
|
||||
if (r.ok) {
|
||||
lines.push(`$ ${cmd} ${args.join(" ")}`);
|
||||
if (r.value.stdout) lines.push(r.value.stdout);
|
||||
if (r.value.stderr) lines.push(r.value.stderr);
|
||||
lines.push("");
|
||||
return true;
|
||||
}
|
||||
const e = r.error;
|
||||
lines.push(`$ ${cmd} ${args.join(" ")} — FAILED`);
|
||||
if (e.kind === "non_zero_exit") {
|
||||
lines.push(`exit ${e.exitCode}`);
|
||||
if (e.stdout) lines.push(e.stdout);
|
||||
if (e.stderr) lines.push(e.stderr);
|
||||
} else if (e.kind === "timeout") {
|
||||
lines.push("timeout");
|
||||
if (e.stdout) lines.push(e.stdout);
|
||||
if (e.stderr) lines.push(e.stderr);
|
||||
} else if (e.kind === "spawn_failed") {
|
||||
lines.push(e.message);
|
||||
} else {
|
||||
lines.push(e.kind === "aborted" ? "aborted" : "error");
|
||||
}
|
||||
lines.push("");
|
||||
return false;
|
||||
};
|
||||
|
||||
await run("git", ["add", "-A"]);
|
||||
const committed = await run("git", ["commit", "-m", "chore(sense): auto-generated commit"]);
|
||||
if (!committed) {
|
||||
success = false;
|
||||
} else {
|
||||
const pushed = await run("git", ["push"]);
|
||||
if (!pushed) success = false;
|
||||
try {
|
||||
return await innerRole(innerStart, _messages);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
content: `committer failed: ${msg}`,
|
||||
meta: { committed: false },
|
||||
};
|
||||
}
|
||||
|
||||
const log = lines.join("\n");
|
||||
writeLog(file, log);
|
||||
|
||||
const summary = success ? "committed and pushed" : "commit/push failed — see log";
|
||||
return {
|
||||
content: `committer: ${summary}\nLog: ${file}`,
|
||||
meta: { success },
|
||||
} satisfies RoleResult<CommitterMeta>;
|
||||
};
|
||||
}
|
||||
|
||||
38
workflows/develop-sense/roles/committer/prompt.ts
Normal file
38
workflows/develop-sense/roles/committer/prompt.ts
Normal file
@ -0,0 +1,38 @@
|
||||
export function workspaceCommitterPrompt({
|
||||
threadId,
|
||||
nerveRoot,
|
||||
workflowName,
|
||||
}: {
|
||||
threadId: string;
|
||||
nerveRoot: string;
|
||||
workflowName: string;
|
||||
}): string {
|
||||
return `You are the **committer** agent (Hermes) for the **${workflowName}** workflow. The coder finished with a passing build; you branch, commit, and push workspace changes.
|
||||
|
||||
## Context
|
||||
|
||||
1. Read the workflow thread: \`nerve thread show ${threadId}\`
|
||||
2. Your git repository root is: \`${nerveRoot}\` — \`cd\` there for all git commands.
|
||||
|
||||
## Steps (in order)
|
||||
|
||||
1. Run \`git status\`. There should be uncommitted changes from the coder. If there is nothing to commit, set **committed** to false and explain.
|
||||
2. Create a short-lived branch (do not commit directly on the default branch if it would mix unrelated work):
|
||||
- Prefer \`fix/<short-slug>\` or \`feat/<short-slug>\` with a lowercase hyphenated slug from the thread (planner/coder context).
|
||||
- Example: \`git checkout -b fix/sense-export-path\`
|
||||
3. \`git add -A\`
|
||||
4. Write a **conventional commit** message summarizing what changed and why (scope may be \`sense\` or similar).
|
||||
5. \`git commit -m "<message>"\` (use multiple \`-m\` if you need a body).
|
||||
6. \`git push -u origin <branch-name>\`
|
||||
|
||||
**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 }
|
||||
\`\`\``;
|
||||
}
|
||||
@ -12,21 +12,21 @@ import { reviewerPrompt } from "./roles/reviewer/prompt.js";
|
||||
import { reviewerMetaSchema } from "./roles/reviewer/index.js";
|
||||
import { testerPrompt } from "./roles/tester/prompt.js";
|
||||
import { testerMetaSchema } from "./roles/tester/index.js";
|
||||
import { buildCommitterRole } from "./roles/committer/index.js";
|
||||
import { buildWorkspaceCommitterRole } from "./roles/committer/index.js";
|
||||
import { moderator } from "./moderator.js";
|
||||
import type { WorkflowMeta } from "./moderator.js";
|
||||
|
||||
export type BuildWorkflowGeneratorDeps = {
|
||||
export type BuildDevelopWorkflowDeps = {
|
||||
extract: LlmExtractorConfig;
|
||||
nerveRoot: string;
|
||||
};
|
||||
|
||||
const CURSOR_TIMEOUT_MS = 300_000;
|
||||
|
||||
export function buildWorkflowGenerator({
|
||||
export function buildDevelopWorkflow({
|
||||
extract,
|
||||
nerveRoot,
|
||||
}: BuildWorkflowGeneratorDeps): WorkflowDefinition<WorkflowMeta> {
|
||||
}: BuildDevelopWorkflowDeps): WorkflowDefinition<WorkflowMeta> {
|
||||
const roles = {
|
||||
planner: createRole(
|
||||
createCursorAdapter({
|
||||
@ -59,7 +59,11 @@ export function buildWorkflowGenerator({
|
||||
testerMetaSchema,
|
||||
extract,
|
||||
),
|
||||
committer: buildCommitterRole({ nerveRoot }),
|
||||
committer: buildWorkspaceCommitterRole({
|
||||
extract,
|
||||
nerveRoot,
|
||||
workflowName: "develop-workflow",
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { join } from "node:path";
|
||||
import { buildWorkflowGenerator } from "./build.js";
|
||||
import { buildDevelopWorkflow } from "./build.js";
|
||||
|
||||
const HOME = process.env.HOME ?? "/home/azureuser";
|
||||
const NERVE_ROOT = join(HOME, ".uncaged-nerve");
|
||||
@ -12,7 +12,7 @@ if (!apiKey || !baseUrl) {
|
||||
throw new Error("Set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
|
||||
}
|
||||
|
||||
const workflow = buildWorkflowGenerator({
|
||||
const workflow = buildDevelopWorkflow({
|
||||
extract: { provider: { apiKey, baseUrl, model } },
|
||||
nerveRoot: NERVE_ROOT,
|
||||
});
|
||||
|
||||
@ -25,7 +25,7 @@ function totalRejections(steps: { role: string; meta: unknown }[]): number {
|
||||
return steps.filter((s) => {
|
||||
if (s.role === "reviewer") return !(s.meta as Record<string, boolean>).approved;
|
||||
if (s.role === "tester") return !(s.meta as Record<string, boolean>).passed;
|
||||
if (s.role === "committer") return !(s.meta as Record<string, boolean>).success;
|
||||
if (s.role === "committer") return !(s.meta as Record<string, boolean>).committed;
|
||||
return false;
|
||||
}).length;
|
||||
}
|
||||
@ -59,7 +59,7 @@ export const moderator: Moderator<WorkflowMeta> = (context) => {
|
||||
}
|
||||
|
||||
if (last.role === "committer") {
|
||||
if (last.meta.success) return END;
|
||||
if (last.meta.committed) return END;
|
||||
return canRetryCoder(context.steps) ? "coder" : END;
|
||||
}
|
||||
|
||||
|
||||
@ -1,92 +1,62 @@
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Role, RoleResult } from "@uncaged/nerve-core";
|
||||
import { isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils";
|
||||
import type { Role, RoleResult, StartStep } from "@uncaged/nerve-core";
|
||||
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole, isDryRun } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
|
||||
export type CommitterMeta = {
|
||||
success: boolean;
|
||||
};
|
||||
import { workspaceCommitterPrompt } from "./prompt.js";
|
||||
|
||||
export type BuildCommitterDeps = {
|
||||
export const committerMetaSchema = z.object({
|
||||
committed: z
|
||||
.boolean()
|
||||
.describe("true if branch created, changes committed, and pushed successfully"),
|
||||
});
|
||||
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
|
||||
|
||||
export type BuildWorkspaceCommitterDeps = {
|
||||
extract: LlmExtractorConfig;
|
||||
nerveRoot: string;
|
||||
workflowName: string;
|
||||
};
|
||||
|
||||
function logPath(nerveRoot: string): string {
|
||||
return join(nerveRoot, "logs", `committer-${Date.now()}.log`);
|
||||
}
|
||||
export function buildWorkspaceCommitterRole({
|
||||
extract,
|
||||
nerveRoot,
|
||||
workflowName,
|
||||
}: BuildWorkspaceCommitterDeps): Role<CommitterMeta> {
|
||||
const innerRole = createRole(
|
||||
hermesAdapter,
|
||||
async (start: StartStep) =>
|
||||
workspaceCommitterPrompt({
|
||||
threadId: start.meta.threadId,
|
||||
nerveRoot,
|
||||
workflowName,
|
||||
}),
|
||||
committerMetaSchema,
|
||||
extract,
|
||||
);
|
||||
|
||||
function writeLog(path: string, content: string): void {
|
||||
mkdirSync(join(path, ".."), { recursive: true });
|
||||
writeFileSync(path, content, "utf-8");
|
||||
}
|
||||
|
||||
export function buildCommitterRole({ nerveRoot }: BuildCommitterDeps): Role<CommitterMeta> {
|
||||
return async (start, _messages) => {
|
||||
const dry = isDryRun(start);
|
||||
const file = logPath(nerveRoot);
|
||||
|
||||
if (dry) {
|
||||
writeLog(file, "[dry-run] committer skipped\n");
|
||||
return async (start, _messages): Promise<RoleResult<CommitterMeta>> => {
|
||||
if (isDryRun(start)) {
|
||||
return {
|
||||
content: `[dry-run] committer skipped — log: ${file}`,
|
||||
meta: { success: true },
|
||||
} satisfies RoleResult<CommitterMeta>;
|
||||
content: "[dry-run] committer skipped (no git branch/commit/push)",
|
||||
meta: { committed: true },
|
||||
};
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
let success = true;
|
||||
const innerStart = {
|
||||
...start,
|
||||
meta: { ...start.meta, workdir: nerveRoot },
|
||||
} as StartStep;
|
||||
|
||||
const run = async (cmd: string, args: string[]): Promise<boolean> => {
|
||||
const r = await spawnSafe(cmd, args, {
|
||||
cwd: nerveRoot,
|
||||
env: null,
|
||||
timeoutMs: 60_000,
|
||||
dryRun: false,
|
||||
abortSignal: null,
|
||||
});
|
||||
if (r.ok) {
|
||||
lines.push(`$ ${cmd} ${args.join(" ")}`);
|
||||
if (r.value.stdout) lines.push(r.value.stdout);
|
||||
if (r.value.stderr) lines.push(r.value.stderr);
|
||||
lines.push("");
|
||||
return true;
|
||||
}
|
||||
const e = r.error;
|
||||
lines.push(`$ ${cmd} ${args.join(" ")} — FAILED`);
|
||||
if (e.kind === "non_zero_exit") {
|
||||
lines.push(`exit ${e.exitCode}`);
|
||||
if (e.stdout) lines.push(e.stdout);
|
||||
if (e.stderr) lines.push(e.stderr);
|
||||
} else if (e.kind === "timeout") {
|
||||
lines.push("timeout");
|
||||
if (e.stdout) lines.push(e.stdout);
|
||||
if (e.stderr) lines.push(e.stderr);
|
||||
} else if (e.kind === "spawn_failed") {
|
||||
lines.push(e.message);
|
||||
} else {
|
||||
lines.push(e.kind === "aborted" ? "aborted" : "error");
|
||||
}
|
||||
lines.push("");
|
||||
return false;
|
||||
};
|
||||
|
||||
await run("git", ["add", "-A"]);
|
||||
// Use a generic message; git diff will show what actually changed
|
||||
const committed = await run("git", ["commit", "-m", "chore(workflow): auto-generated commit"]);
|
||||
if (!committed) {
|
||||
success = false;
|
||||
} else {
|
||||
const pushed = await run("git", ["push"]);
|
||||
if (!pushed) success = false;
|
||||
try {
|
||||
return await innerRole(innerStart, _messages);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
content: `committer failed: ${msg}`,
|
||||
meta: { committed: false },
|
||||
};
|
||||
}
|
||||
|
||||
const log = lines.join("\n");
|
||||
writeLog(file, log);
|
||||
|
||||
const summary = success ? "committed and pushed" : "commit/push failed — see log";
|
||||
return {
|
||||
content: `committer: ${summary}\nLog: ${file}`,
|
||||
meta: { success },
|
||||
} satisfies RoleResult<CommitterMeta>;
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,35 +1,38 @@
|
||||
export type CommitterPromptParams = {
|
||||
nerveRoot: string;
|
||||
workflowName: string;
|
||||
userPrompt: string;
|
||||
testerReason: string;
|
||||
};
|
||||
|
||||
export function committerPrompt({
|
||||
export function workspaceCommitterPrompt({
|
||||
threadId,
|
||||
nerveRoot,
|
||||
workflowName,
|
||||
userPrompt,
|
||||
testerReason,
|
||||
}: CommitterPromptParams): string {
|
||||
return `You are a git committer subagent for Nerve workflow generation.
|
||||
Repository root: ${nerveRoot}
|
||||
}: {
|
||||
threadId: string;
|
||||
nerveRoot: string;
|
||||
workflowName: string;
|
||||
}): string {
|
||||
return `You are the **committer** agent (Hermes) for the **${workflowName}** workflow. The coder finished with a passing build; you branch, commit, and push workspace changes.
|
||||
|
||||
Goal:
|
||||
- Commit and push generated workflow "${workflowName}".
|
||||
- Handle dirty worktree safely (do not discard unrelated user edits).
|
||||
- Detect default branch automatically.
|
||||
- Create a focused branch for this workflow update.
|
||||
- Stage only workflow files and required config updates.
|
||||
## Context
|
||||
|
||||
Context:
|
||||
- User prompt summary: ${userPrompt.slice(0, 500)}
|
||||
- Tester result: ${testerReason}
|
||||
1. Read the workflow thread: \`nerve thread show ${threadId}\`
|
||||
2. Your git repository root is: \`${nerveRoot}\` — \`cd\` there for all git commands.
|
||||
|
||||
Expected output format:
|
||||
BRANCH=<branch-or-empty>
|
||||
COMMIT=<hash-or-empty>
|
||||
PUSHED=<true|false|unknown>
|
||||
LOG_START
|
||||
<details>
|
||||
LOG_END`;
|
||||
## Steps (in order)
|
||||
|
||||
1. Run \`git status\`. There should be uncommitted changes from the coder. If there is nothing to commit, set **committed** to false and explain.
|
||||
2. Create a short-lived branch (do not commit directly on the default branch if it would mix unrelated work):
|
||||
- Prefer \`fix/<short-slug>\` or \`feat/<short-slug>\` with a lowercase hyphenated slug from the thread (planner/coder context).
|
||||
- Example: \`git checkout -b feat/workflow-new-step\`
|
||||
3. \`git add -A\`
|
||||
4. Write a **conventional commit** message summarizing what changed and why (scope may be \`workflow\` or similar).
|
||||
5. \`git commit -m "<message>"\` (use multiple \`-m\` if you need a body).
|
||||
6. \`git push -u origin <branch-name>\`
|
||||
|
||||
**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 }
|
||||
\`\`\``;
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
|
||||
import { moderator } from "./moderator.js";
|
||||
import type { WorkflowMeta } from "./moderator.js";
|
||||
import { buildCommitterRole } from "./roles/committer/index.js";
|
||||
import { buildImplementRole } from "./roles/implement/index.js";
|
||||
import { buildPlanRole } from "./roles/plan/index.js";
|
||||
import { prepareMetaSchema } from "./roles/prepare/index.js";
|
||||
@ -43,6 +44,7 @@ export function buildSolveIssue({
|
||||
),
|
||||
plan: buildPlanRole({ extract, nerveRoot }),
|
||||
implement: buildImplementRole({ extract, nerveRoot }),
|
||||
committer: buildCommitterRole({ extract, nerveRoot }),
|
||||
review: createRole(
|
||||
hermesAdapter,
|
||||
async (start: StartStep) =>
|
||||
|
||||
@ -4,6 +4,7 @@ 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 { CommitterMeta } from "./roles/committer/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";
|
||||
@ -13,6 +14,7 @@ export type WorkflowMeta = {
|
||||
prepare: PrepareMeta;
|
||||
plan: PlanMeta;
|
||||
implement: ImplementMeta;
|
||||
committer: CommitterMeta;
|
||||
review: ReviewMeta;
|
||||
test: TestMeta;
|
||||
publish: PublishMeta;
|
||||
@ -29,6 +31,7 @@ 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 === "committer") return !(s.meta as Record<string, boolean>).committed;
|
||||
if (s.role === "publish") return !(s.meta as Record<string, boolean>).success;
|
||||
return false;
|
||||
}).length;
|
||||
@ -59,6 +62,13 @@ export const moderator: Moderator<WorkflowMeta> = (context) => {
|
||||
|
||||
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;
|
||||
|
||||
65
workflows/solve-issue/roles/committer/index.ts
Normal file
65
workflows/solve-issue/roles/committer/index.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
|
||||
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole, isDryRun } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
|
||||
import { resolveRepoCwd } from "../../lib/repo-context.js";
|
||||
import { committerPrompt } from "./prompt.js";
|
||||
|
||||
export const committerMetaSchema = z.object({
|
||||
committed: z
|
||||
.boolean()
|
||||
.describe("true if branch created, changes committed, and pushed successfully"),
|
||||
});
|
||||
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
|
||||
|
||||
export type BuildCommitterDeps = {
|
||||
extract: LlmExtractorConfig;
|
||||
nerveRoot: string;
|
||||
};
|
||||
|
||||
export function buildCommitterRole({ extract, nerveRoot }: BuildCommitterDeps): Role<CommitterMeta> {
|
||||
const innerRole = createRole(
|
||||
hermesAdapter,
|
||||
async (start: StartStep) =>
|
||||
committerPrompt({
|
||||
threadId: start.meta.threadId,
|
||||
nerveRoot,
|
||||
}),
|
||||
committerMetaSchema,
|
||||
extract,
|
||||
);
|
||||
|
||||
return async (start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<CommitterMeta>> => {
|
||||
const cwd = resolveRepoCwd(messages);
|
||||
if (cwd === null) {
|
||||
return {
|
||||
content: "committer cannot run: missing repo path in thread markers",
|
||||
meta: { committed: false },
|
||||
};
|
||||
}
|
||||
|
||||
if (isDryRun(start)) {
|
||||
return {
|
||||
content: "[dry-run] committer skipped (no git branch/commit/push)",
|
||||
meta: { committed: true },
|
||||
};
|
||||
}
|
||||
|
||||
const innerStart = {
|
||||
...start,
|
||||
meta: { ...start.meta, workdir: cwd },
|
||||
} as StartStep;
|
||||
|
||||
try {
|
||||
return await innerRole(innerStart, messages);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
content: `committer failed: ${msg}`,
|
||||
meta: { committed: false },
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
46
workflows/solve-issue/roles/committer/prompt.ts
Normal file
46
workflows/solve-issue/roles/committer/prompt.ts
Normal file
@ -0,0 +1,46 @@
|
||||
export function committerPrompt({
|
||||
threadId,
|
||||
nerveRoot,
|
||||
}: {
|
||||
threadId: string;
|
||||
nerveRoot: string;
|
||||
}): string {
|
||||
return `You are the **committer** agent (Hermes). The **implement** step finished with a passing build; your job is to put those changes on a feature branch and push to **origin**.
|
||||
|
||||
## Context
|
||||
|
||||
- Read the full workflow thread: \`nerve thread show ${threadId}\`
|
||||
- Optional workspace tone: \`cat ${nerveRoot}/CONVENTIONS.md\`
|
||||
|
||||
## Find repo and issue markers
|
||||
|
||||
In the thread, locate \`---SOLVE_ISSUE_PARSE---\` and \`---SOLVE_ISSUE_REPO---\`. From them you need:
|
||||
|
||||
- Issue **number** and **title** (from PARSE; title for the branch slug)
|
||||
- Repo checkout **path** (from REPO \`path\`) — this is your working copy; your shell cwd should be this directory.
|
||||
|
||||
## Steps (in order)
|
||||
|
||||
1. \`cd\` to the repo **path** from SOLVE_ISSUE_REPO.
|
||||
2. Run \`git status\`. There should be **uncommitted** changes from implement. If the tree is clean with nothing to commit, set **committed** to false and explain.
|
||||
3. Create a feature branch (do not work on the default branch directly):
|
||||
- Name: \`fix/<number>-<short-slug>\` for fixes, or \`feat/<number>-<short-slug>\` if the issue is clearly a feature.
|
||||
- **number** from SOLVE_ISSUE_PARSE.
|
||||
- **slug**: lowercase, hyphens only, short (from issue title words).
|
||||
- Example: \`git checkout -b fix/42-auth-timeout\`
|
||||
4. \`git add -A\`
|
||||
5. Write a **conventional commit** message (e.g. \`fix(scope): summary\` or \`feat(scope): summary\`) describing **what** changed and **why**, using the thread (plan + implement context).
|
||||
6. \`git commit -m "<message>"\` — use a single \`-m\` for a one-line message, or multiple \`-m\` for body paragraphs if needed.
|
||||
7. \`git push -u origin <branch-name>\`
|
||||
|
||||
**committed=true** only if you created the branch, committed successfully, and **push** succeeded.
|
||||
|
||||
End your reply with a JSON line:
|
||||
\`\`\`json
|
||||
{ "committed": true }
|
||||
\`\`\`
|
||||
or
|
||||
\`\`\`json
|
||||
{ "committed": false }
|
||||
\`\`\``;
|
||||
}
|
||||
@ -9,10 +9,11 @@ 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.
|
||||
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\`. Branching and commits are handled by the **committer** step after you finish.
|
||||
|
||||
Then close with JSON:
|
||||
\`\`\`json
|
||||
|
||||
@ -17,15 +17,15 @@ Find \`---SOLVE_ISSUE_PARSE---\` and \`---SOLVE_ISSUE_REPO---\` in prior message
|
||||
|
||||
## Steps (in order)
|
||||
|
||||
1. \`cd\` to the **repo \`path\`**. Run \`git rev-parse --abbrev-ref HEAD\` to get the current branch name.
|
||||
2. \`git push -u origin <that-branch>\` (must succeed before PR).
|
||||
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 <that-branch>\` 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** — the issue link above
|
||||
4. Create the PR with **tea** (not curl/fetch to Gitea):
|
||||
- \`tea pr create --repo <owner>/<repo> --base <defaultBranch> --head <branch> --title "fix: issue #<number>" --body <your markdown body>\`
|
||||
- \`tea pr create --repo <owner>/<repo> --base <defaultBranch> --head <branch> --title "<your meaningful title>" --body <your markdown 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).
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user