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:
小橘 🍊(NEKO Team) 2026-04-29 10:44:28 +00:00 committed by 小橘
parent 3a2b8a49a3
commit c585e0d8a8
16 changed files with 325 additions and 211 deletions

View File

@ -12,21 +12,21 @@ import { reviewerPrompt } from "./roles/reviewer/prompt.js";
import { reviewerMetaSchema } from "./roles/reviewer/index.js"; import { reviewerMetaSchema } from "./roles/reviewer/index.js";
import { testerPrompt } from "./roles/tester/prompt.js"; import { testerPrompt } from "./roles/tester/prompt.js";
import { testerMetaSchema } from "./roles/tester/index.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 { moderator } from "./moderator.js";
import type { SenseMeta } from "./moderator.js"; import type { SenseMeta } from "./moderator.js";
export type BuildSenseGeneratorDeps = { export type BuildDevelopSenseDeps = {
extract: LlmExtractorConfig; extract: LlmExtractorConfig;
cwd: string; cwd: string;
}; };
const CURSOR_TIMEOUT_MS = 300_000; const CURSOR_TIMEOUT_MS = 300_000;
export function buildSenseGenerator({ export function buildDevelopSenseWorkflow({
extract, extract,
cwd, cwd,
}: BuildSenseGeneratorDeps): WorkflowDefinition<SenseMeta> { }: BuildDevelopSenseDeps): WorkflowDefinition<SenseMeta> {
const roles = { const roles = {
planner: createRole( planner: createRole(
createCursorAdapter({ createCursorAdapter({
@ -59,7 +59,11 @@ export function buildSenseGenerator({
testerMetaSchema, testerMetaSchema,
extract, extract,
), ),
committer: buildCommitterRole({ nerveRoot: cwd }), committer: buildWorkspaceCommitterRole({
extract,
nerveRoot: cwd,
workflowName: "develop-sense",
}),
}; };
return { return {

View File

@ -1,5 +1,5 @@
import { join } from "node:path"; import { join } from "node:path";
import { buildSenseGenerator } from "./build.js"; import { buildDevelopSenseWorkflow } from "./build.js";
const HOME = process.env.HOME ?? "/home/azureuser"; const HOME = process.env.HOME ?? "/home/azureuser";
const NERVE_ROOT = join(HOME, ".uncaged-nerve"); 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"); throw new Error("Set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
} }
const workflow = buildSenseGenerator({ const workflow = buildDevelopSenseWorkflow({
extract: { provider: { apiKey, baseUrl, model } }, extract: { provider: { apiKey, baseUrl, model } },
cwd: NERVE_ROOT, cwd: NERVE_ROOT,
}); });

View File

@ -25,7 +25,7 @@ function totalRejections(steps: { role: string; meta: unknown }[]): number {
return steps.filter((s) => { return steps.filter((s) => {
if (s.role === "reviewer") return !(s.meta as Record<string, boolean>).approved; 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 === "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; return false;
}).length; }).length;
} }
@ -57,7 +57,7 @@ export const moderator: Moderator<SenseMeta> = (context) => {
} }
if (last.role === "committer") { if (last.role === "committer") {
if (last.meta.success) return END; if (last.meta.committed) return END;
return canRetryCoder(context.steps) ? "coder" : END; return canRetryCoder(context.steps) ? "coder" : END;
} }

View File

@ -1,91 +1,62 @@
import { writeFileSync, mkdirSync } from "node:fs"; import type { Role, RoleResult, StartStep } from "@uncaged/nerve-core";
import { join } from "node:path"; import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
import type { Role, RoleResult } from "@uncaged/nerve-core"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils"; import { createRole, isDryRun } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
export type CommitterMeta = { import { workspaceCommitterPrompt } from "./prompt.js";
success: boolean;
};
export type BuildCommitterDeps = { export const committerMetaSchema = z.object({
nerveRoot: string; committed: z
}; .boolean()
.describe("true if branch created, changes committed, and pushed successfully"),
function logPath(nerveRoot: string): string {
return join(nerveRoot, "logs", `committer-${Date.now()}.log`);
}
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 {
content: `[dry-run] committer skipped — log: ${file}`,
meta: { success: true },
} satisfies RoleResult<CommitterMeta>;
}
const lines: string[] = [];
let success = true;
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) { export type CommitterMeta = z.infer<typeof committerMetaSchema>;
lines.push(`$ ${cmd} ${args.join(" ")}`);
if (r.value.stdout) lines.push(r.value.stdout); export type BuildWorkspaceCommitterDeps = {
if (r.value.stderr) lines.push(r.value.stderr); extract: LlmExtractorConfig;
lines.push(""); nerveRoot: string;
return true; workflowName: string;
}
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"]); export function buildWorkspaceCommitterRole({
const committed = await run("git", ["commit", "-m", "chore(sense): auto-generated commit"]); extract,
if (!committed) { nerveRoot,
success = false; workflowName,
} else { }: BuildWorkspaceCommitterDeps): Role<CommitterMeta> {
const pushed = await run("git", ["push"]); const innerRole = createRole(
if (!pushed) success = false; hermesAdapter,
} async (start: StartStep) =>
workspaceCommitterPrompt({
threadId: start.meta.threadId,
nerveRoot,
workflowName,
}),
committerMetaSchema,
extract,
);
const log = lines.join("\n"); return async (start, _messages): Promise<RoleResult<CommitterMeta>> => {
writeLog(file, log); if (isDryRun(start)) {
const summary = success ? "committed and pushed" : "commit/push failed — see log";
return { return {
content: `committer: ${summary}\nLog: ${file}`, content: "[dry-run] committer skipped (no git branch/commit/push)",
meta: { success }, meta: { committed: true },
} satisfies RoleResult<CommitterMeta>; };
}
const innerStart = {
...start,
meta: { ...start.meta, workdir: nerveRoot },
} 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 },
};
}
}; };
} }

View 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 }
\`\`\``;
}

View File

@ -12,21 +12,21 @@ import { reviewerPrompt } from "./roles/reviewer/prompt.js";
import { reviewerMetaSchema } from "./roles/reviewer/index.js"; import { reviewerMetaSchema } from "./roles/reviewer/index.js";
import { testerPrompt } from "./roles/tester/prompt.js"; import { testerPrompt } from "./roles/tester/prompt.js";
import { testerMetaSchema } from "./roles/tester/index.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 { moderator } from "./moderator.js";
import type { WorkflowMeta } from "./moderator.js"; import type { WorkflowMeta } from "./moderator.js";
export type BuildWorkflowGeneratorDeps = { export type BuildDevelopWorkflowDeps = {
extract: LlmExtractorConfig; extract: LlmExtractorConfig;
nerveRoot: string; nerveRoot: string;
}; };
const CURSOR_TIMEOUT_MS = 300_000; const CURSOR_TIMEOUT_MS = 300_000;
export function buildWorkflowGenerator({ export function buildDevelopWorkflow({
extract, extract,
nerveRoot, nerveRoot,
}: BuildWorkflowGeneratorDeps): WorkflowDefinition<WorkflowMeta> { }: BuildDevelopWorkflowDeps): WorkflowDefinition<WorkflowMeta> {
const roles = { const roles = {
planner: createRole( planner: createRole(
createCursorAdapter({ createCursorAdapter({
@ -59,7 +59,11 @@ export function buildWorkflowGenerator({
testerMetaSchema, testerMetaSchema,
extract, extract,
), ),
committer: buildCommitterRole({ nerveRoot }), committer: buildWorkspaceCommitterRole({
extract,
nerveRoot,
workflowName: "develop-workflow",
}),
}; };
return { return {

View File

@ -1,5 +1,5 @@
import { join } from "node:path"; import { join } from "node:path";
import { buildWorkflowGenerator } from "./build.js"; import { buildDevelopWorkflow } from "./build.js";
const HOME = process.env.HOME ?? "/home/azureuser"; const HOME = process.env.HOME ?? "/home/azureuser";
const NERVE_ROOT = join(HOME, ".uncaged-nerve"); 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"); throw new Error("Set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
} }
const workflow = buildWorkflowGenerator({ const workflow = buildDevelopWorkflow({
extract: { provider: { apiKey, baseUrl, model } }, extract: { provider: { apiKey, baseUrl, model } },
nerveRoot: NERVE_ROOT, nerveRoot: NERVE_ROOT,
}); });

View File

@ -25,7 +25,7 @@ function totalRejections(steps: { role: string; meta: unknown }[]): number {
return steps.filter((s) => { return steps.filter((s) => {
if (s.role === "reviewer") return !(s.meta as Record<string, boolean>).approved; 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 === "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; return false;
}).length; }).length;
} }
@ -59,7 +59,7 @@ export const moderator: Moderator<WorkflowMeta> = (context) => {
} }
if (last.role === "committer") { if (last.role === "committer") {
if (last.meta.success) return END; if (last.meta.committed) return END;
return canRetryCoder(context.steps) ? "coder" : END; return canRetryCoder(context.steps) ? "coder" : END;
} }

View File

@ -1,92 +1,62 @@
import { writeFileSync, mkdirSync } from "node:fs"; import type { Role, RoleResult, StartStep } from "@uncaged/nerve-core";
import { join } from "node:path"; import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
import type { Role, RoleResult } from "@uncaged/nerve-core"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils"; import { createRole, isDryRun } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
export type CommitterMeta = { import { workspaceCommitterPrompt } from "./prompt.js";
success: boolean;
};
export type BuildCommitterDeps = { export const committerMetaSchema = z.object({
nerveRoot: string; committed: z
}; .boolean()
.describe("true if branch created, changes committed, and pushed successfully"),
function logPath(nerveRoot: string): string {
return join(nerveRoot, "logs", `committer-${Date.now()}.log`);
}
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 {
content: `[dry-run] committer skipped — log: ${file}`,
meta: { success: true },
} satisfies RoleResult<CommitterMeta>;
}
const lines: string[] = [];
let success = true;
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) { export type CommitterMeta = z.infer<typeof committerMetaSchema>;
lines.push(`$ ${cmd} ${args.join(" ")}`);
if (r.value.stdout) lines.push(r.value.stdout); export type BuildWorkspaceCommitterDeps = {
if (r.value.stderr) lines.push(r.value.stderr); extract: LlmExtractorConfig;
lines.push(""); nerveRoot: string;
return true; workflowName: string;
}
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"]); export function buildWorkspaceCommitterRole({
// Use a generic message; git diff will show what actually changed extract,
const committed = await run("git", ["commit", "-m", "chore(workflow): auto-generated commit"]); nerveRoot,
if (!committed) { workflowName,
success = false; }: BuildWorkspaceCommitterDeps): Role<CommitterMeta> {
} else { const innerRole = createRole(
const pushed = await run("git", ["push"]); hermesAdapter,
if (!pushed) success = false; async (start: StartStep) =>
} workspaceCommitterPrompt({
threadId: start.meta.threadId,
nerveRoot,
workflowName,
}),
committerMetaSchema,
extract,
);
const log = lines.join("\n"); return async (start, _messages): Promise<RoleResult<CommitterMeta>> => {
writeLog(file, log); if (isDryRun(start)) {
const summary = success ? "committed and pushed" : "commit/push failed — see log";
return { return {
content: `committer: ${summary}\nLog: ${file}`, content: "[dry-run] committer skipped (no git branch/commit/push)",
meta: { success }, meta: { committed: true },
} satisfies RoleResult<CommitterMeta>; };
}
const innerStart = {
...start,
meta: { ...start.meta, workdir: nerveRoot },
} 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 },
};
}
}; };
} }

View File

@ -1,35 +1,38 @@
export type CommitterPromptParams = { export function workspaceCommitterPrompt({
nerveRoot: string; threadId,
workflowName: string;
userPrompt: string;
testerReason: string;
};
export function committerPrompt({
nerveRoot, nerveRoot,
workflowName, workflowName,
userPrompt, }: {
testerReason, threadId: string;
}: CommitterPromptParams): string { nerveRoot: string;
return `You are a git committer subagent for Nerve workflow generation. workflowName: string;
Repository root: ${nerveRoot} }): 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: ## Context
- 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: 1. Read the workflow thread: \`nerve thread show ${threadId}\`
- User prompt summary: ${userPrompt.slice(0, 500)} 2. Your git repository root is: \`${nerveRoot}\`\`cd\` there for all git commands.
- Tester result: ${testerReason}
Expected output format: ## Steps (in order)
BRANCH=<branch-or-empty>
COMMIT=<hash-or-empty> 1. Run \`git status\`. There should be uncommitted changes from the coder. If there is nothing to commit, set **committed** to false and explain.
PUSHED=<true|false|unknown> 2. Create a short-lived branch (do not commit directly on the default branch if it would mix unrelated work):
LOG_START - Prefer \`fix/<short-slug>\` or \`feat/<short-slug>\` with a lowercase hyphenated slug from the thread (planner/coder context).
<details> - Example: \`git checkout -b feat/workflow-new-step\`
LOG_END`; 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 }
\`\`\``;
} }

View File

@ -5,6 +5,7 @@ import { createRole } from "@uncaged/nerve-workflow-utils";
import { moderator } from "./moderator.js"; import { moderator } from "./moderator.js";
import type { WorkflowMeta } from "./moderator.js"; import type { WorkflowMeta } from "./moderator.js";
import { buildCommitterRole } from "./roles/committer/index.js";
import { buildImplementRole } from "./roles/implement/index.js"; import { buildImplementRole } from "./roles/implement/index.js";
import { buildPlanRole } from "./roles/plan/index.js"; import { buildPlanRole } from "./roles/plan/index.js";
import { prepareMetaSchema } from "./roles/prepare/index.js"; import { prepareMetaSchema } from "./roles/prepare/index.js";
@ -43,6 +44,7 @@ export function buildSolveIssue({
), ),
plan: buildPlanRole({ extract, nerveRoot }), plan: buildPlanRole({ extract, nerveRoot }),
implement: buildImplementRole({ extract, nerveRoot }), implement: buildImplementRole({ extract, nerveRoot }),
committer: buildCommitterRole({ extract, nerveRoot }),
review: createRole( review: createRole(
hermesAdapter, hermesAdapter,
async (start: StartStep) => async (start: StartStep) =>

View File

@ -4,6 +4,7 @@ import type { ReadIssueMeta } from "./roles/read-issue/index.js";
import type { PrepareMeta } from "./roles/prepare/index.js"; import type { PrepareMeta } from "./roles/prepare/index.js";
import type { PlanMeta } from "./roles/plan/index.js"; import type { PlanMeta } from "./roles/plan/index.js";
import type { ImplementMeta } from "./roles/implement/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 { ReviewMeta } from "./roles/review/index.js";
import type { TestMeta } from "./roles/test/index.js"; import type { TestMeta } from "./roles/test/index.js";
import type { PublishMeta } from "./roles/publish/index.js"; import type { PublishMeta } from "./roles/publish/index.js";
@ -13,6 +14,7 @@ export type WorkflowMeta = {
prepare: PrepareMeta; prepare: PrepareMeta;
plan: PlanMeta; plan: PlanMeta;
implement: ImplementMeta; implement: ImplementMeta;
committer: CommitterMeta;
review: ReviewMeta; review: ReviewMeta;
test: TestMeta; test: TestMeta;
publish: PublishMeta; publish: PublishMeta;
@ -29,6 +31,7 @@ function totalRejections(steps: { role: string; meta: unknown }[]): number {
return steps.filter((s) => { return steps.filter((s) => {
if (s.role === "review") return !(s.meta as Record<string, boolean>).approved; 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 === "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; if (s.role === "publish") return !(s.meta as Record<string, boolean>).success;
return false; return false;
}).length; }).length;
@ -59,6 +62,13 @@ export const moderator: Moderator<WorkflowMeta> = (context) => {
if (last.role === "implement") { if (last.role === "implement") {
if (last.meta.done) { if (last.meta.done) {
return "committer";
}
return canRetryImplement(context.steps) ? "implement" : END;
}
if (last.role === "committer") {
if (last.meta.committed) {
return "review"; return "review";
} }
return canRetryImplement(context.steps) ? "implement" : END; return canRetryImplement(context.steps) ? "implement" : END;

View 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 },
};
}
};
}

View 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 }
\`\`\``;
}

View File

@ -9,10 +9,11 @@ Your cwd is the target repository.
## Requirements ## 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). 1. Implement the planned changes; address reviewer/tester feedback from the thread if any.
2. 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. 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.
4. 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: Then close with JSON:
\`\`\`json \`\`\`json

View File

@ -17,15 +17,15 @@ Find \`---SOLVE_ISSUE_PARSE---\` and \`---SOLVE_ISSUE_REPO---\` in prior message
## Steps (in order) ## Steps (in order)
1. \`cd\` to the **repo \`path\`**. Run \`git rev-parse --abbrev-ref HEAD\` to get the current branch name. 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. \`git push -u origin <that-branch>\` (must succeed before PR). 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): 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 - **## What** one short paragraph: what this PR does
- **## Why** one short paragraph: motivation / issue - **## Why** one short paragraph: motivation / issue
- **## Changes** bullet list of notable changes - **## Changes** bullet list of notable changes
- **## Ref** the issue link above - **## Ref** the issue link above
4. Create the PR with **tea** (not curl/fetch to Gitea): 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. - 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). 5. Confirm the PR was created (tea prints a URL or PR number in typical setups).