feat(workflow-generator): add committer role
Auto-generated by workflow-generator itself. After reviewer passes,
committer creates a feature branch, stages workflow files, commits
with a meaningful message, and pushes to origin.
小橘 🍊(NEKO Team)
This commit is contained in:
parent
0803a00482
commit
028125b74f
@ -1,4 +1,5 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
RoleResult,
|
||||
@ -63,6 +64,13 @@ function formatSpawnFailure(error: SpawnError): string {
|
||||
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 400)}`;
|
||||
}
|
||||
|
||||
function spawnErrorStreams(error: SpawnError): { stdout: string; stderr: string } {
|
||||
if (error.kind === "spawn_failed") {
|
||||
return { stdout: "", stderr: "" };
|
||||
}
|
||||
return { stdout: error.stdout, stderr: error.stderr };
|
||||
}
|
||||
|
||||
function buildSenseGeneratorReference(): string {
|
||||
const ref = join(WORKFLOWS_DIR, "sense-generator", "index.ts");
|
||||
if (!existsSync(ref)) {
|
||||
@ -82,9 +90,12 @@ function lastMetaForRole<M>(messages: WorkflowMessage[], role: string): M | null
|
||||
|
||||
const roleEntrySchema = z
|
||||
.object({
|
||||
name: z.string().describe("Role key / identifier in kebab-case or short snake name"),
|
||||
description: z.string().describe("What this role does in one or two sentences"),
|
||||
responsibilities: z.string().describe("Concrete responsibilities, inputs, and outputs for this role"),
|
||||
name: z.string().default("").describe("Role key / identifier in kebab-case or short snake name"),
|
||||
description: z.string().default("").describe("What this role does in one or two sentences"),
|
||||
responsibilities: z
|
||||
.string()
|
||||
.default("")
|
||||
.describe("Concrete responsibilities, inputs, and outputs for this role"),
|
||||
})
|
||||
.describe("One role in the generated workflow");
|
||||
|
||||
@ -92,14 +103,20 @@ const analystExtractSchema = z
|
||||
.object({
|
||||
workflowName: z
|
||||
.string()
|
||||
.default("")
|
||||
.describe("kebab-case package directory name under workflows/, e.g. 'ticket-triage'"),
|
||||
roles: z.array(roleEntrySchema).describe("Planned roles for the new workflow"),
|
||||
moderatorFlow: z.string().describe("How the moderator should route between roles; start and exit conditions"),
|
||||
roles: z.array(roleEntrySchema).default([]).describe("Planned roles for the new workflow"),
|
||||
moderatorFlow: z
|
||||
.string()
|
||||
.default("")
|
||||
.describe("How the moderator should route between roles; start and exit conditions"),
|
||||
externalDeps: z
|
||||
.string()
|
||||
.default("")
|
||||
.describe("External tools, CLIs, HTTP APIs, or services the workflow must integrate with"),
|
||||
dataFlow: z
|
||||
.string()
|
||||
.default("")
|
||||
.describe("How data moves between roles: what each step consumes and produces in content/meta"),
|
||||
})
|
||||
.describe("Structured workflow specification extracted from the analysis");
|
||||
@ -110,7 +127,7 @@ type AnalystMetaItem = {
|
||||
responsibilities: string;
|
||||
};
|
||||
|
||||
type WorkflowGenMeta = {
|
||||
type WorkflowMeta = {
|
||||
analyst: {
|
||||
userPrompt: string;
|
||||
analysis: string;
|
||||
@ -133,9 +150,17 @@ type WorkflowGenMeta = {
|
||||
attempt: number;
|
||||
validationLog: string;
|
||||
};
|
||||
committer: {
|
||||
branch: string | null;
|
||||
commitHash: string | null;
|
||||
pushed: boolean | null;
|
||||
skipped: boolean;
|
||||
error: string | null;
|
||||
stagedPaths: string[];
|
||||
};
|
||||
};
|
||||
|
||||
const emptyAnalystMeta = (userContent: string): WorkflowGenMeta["analyst"] => ({
|
||||
const emptyAnalystMeta = (userContent: string): WorkflowMeta["analyst"] => ({
|
||||
userPrompt: userContent,
|
||||
analysis: "",
|
||||
workflowName: "",
|
||||
@ -164,7 +189,7 @@ function scanGeneratedCodePitfalls(source: string): string[] {
|
||||
const issues: string[] = [];
|
||||
if (/\bawait\s+import\s*\(/.test(source)) {
|
||||
issues.push(
|
||||
"Uses await import() — only allowed in sense-runtime / workflow-worker with a documented comment",
|
||||
"Uses the await keyword with a parenthesized import() call — only allowed in sense-runtime / workflow-worker with a documented comment",
|
||||
);
|
||||
}
|
||||
if (/\bimport\s*\(\s*["'`]/.test(source) && !source.includes("Dynamic import required")) {
|
||||
@ -233,14 +258,198 @@ async function runReviewerValidation(
|
||||
return { ok: true, log: logParts.join("\n\n") };
|
||||
}
|
||||
|
||||
const workflow: WorkflowDefinition<WorkflowGenMeta> = {
|
||||
function summarizeText(s: string, maxLen: number): string {
|
||||
const one = s.replace(/\s+/g, " ").trim();
|
||||
if (one.length <= maxLen) {
|
||||
return one;
|
||||
}
|
||||
return `${one.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
|
||||
function sanitizeBranchSegment(name: string): string {
|
||||
const t = name
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return t.length > 0 ? t : "workflow";
|
||||
}
|
||||
|
||||
function resolveWorkflowNameForCommitter(messages: WorkflowMessage[]): string {
|
||||
const rev = lastMetaForRole<WorkflowMeta["reviewer"]>(messages, "reviewer");
|
||||
if (rev !== null && rev.workflowName.trim().length > 0) {
|
||||
return rev.workflowName.trim();
|
||||
}
|
||||
const coder = lastMetaForRole<WorkflowMeta["coder"]>(messages, "coder");
|
||||
if (coder !== null && coder.workflowName.trim().length > 0) {
|
||||
return coder.workflowName.trim();
|
||||
}
|
||||
const analyst = lastMetaForRole<WorkflowMeta["analyst"]>(messages, "analyst");
|
||||
if (analyst !== null && analyst.workflowName.trim().length > 0) {
|
||||
return analyst.workflowName.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildCoreStagePaths(
|
||||
workflowName: string,
|
||||
files: WorkflowMeta["coder"]["files"],
|
||||
includeNerveYaml: boolean,
|
||||
): string[] {
|
||||
const base = `workflows/${workflowName}`;
|
||||
const paths: string[] = [];
|
||||
if (files.indexTs) {
|
||||
paths.push(`${base}/index.ts`);
|
||||
}
|
||||
if (files.packageJson) {
|
||||
paths.push(`${base}/package.json`);
|
||||
const lockRel = `${base}/pnpm-lock.yaml`;
|
||||
if (existsSync(join(NERVE_ROOT, lockRel))) {
|
||||
paths.push(lockRel);
|
||||
}
|
||||
}
|
||||
if (files.tsconfigJson) {
|
||||
paths.push(`${base}/tsconfig.json`);
|
||||
}
|
||||
if (includeNerveYaml) {
|
||||
paths.push("nerve.yaml");
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function nerveYamlShouldBeStaged(workflowName: string, start: StartStep): Promise<boolean> {
|
||||
const dry = isDryRun(start);
|
||||
if (dry) {
|
||||
return verifyNerveWorkflowEntry(workflowName).ok;
|
||||
}
|
||||
const st = await spawnSafe("git", ["status", "--porcelain", "--", "nerve.yaml"], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 30_000,
|
||||
dryRun: false,
|
||||
});
|
||||
if (st.ok && st.value.stdout.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
const d1 = await spawnSafe("git", ["diff", "--name-only", "--", "nerve.yaml"], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 30_000,
|
||||
dryRun: false,
|
||||
});
|
||||
if (d1.ok && d1.value.stdout.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
const d2 = await spawnSafe("git", ["diff", "--cached", "--name-only", "--", "nerve.yaml"], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 30_000,
|
||||
dryRun: false,
|
||||
});
|
||||
return d2.ok && d2.value.stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
async function listUntrackedUnderWorkflowDir(workflowName: string, start: StartStep): Promise<string[]> {
|
||||
const dry = isDryRun(start);
|
||||
if (dry) {
|
||||
return [];
|
||||
}
|
||||
const prefix = `workflows/${workflowName}/`;
|
||||
const r = await spawnSafe("git", ["status", "--porcelain", "-u", "--", prefix], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 30_000,
|
||||
dryRun: false,
|
||||
});
|
||||
if (!r.ok) {
|
||||
return [];
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (const line of r.value.stdout.split("\n")) {
|
||||
const t = line.trimEnd();
|
||||
if (t.startsWith("?? ")) {
|
||||
const p = t.slice(3).trim();
|
||||
if (p.startsWith(prefix)) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function resolveDefaultBranchName(start: StartStep, logLines: string[]): Promise<string> {
|
||||
const dry = isDryRun(start);
|
||||
const sym = await spawnSafe("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 10_000,
|
||||
dryRun: dry,
|
||||
});
|
||||
if (sym.ok) {
|
||||
const out = sym.value.stdout.trim();
|
||||
if (out.length > 0 && !out.includes("[dryRun]")) {
|
||||
const m = out.match(/refs\/remotes\/origin\/(.+)$/);
|
||||
if (m !== null && m[1] !== undefined && m[1].length > 0) {
|
||||
logLines.push(`[branch] default via symbolic-ref: ${m[1]}`);
|
||||
return m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dry) {
|
||||
logLines.push("[branch] dry-run: assuming default branch name `main`");
|
||||
return "main";
|
||||
}
|
||||
const abbrev = await spawnSafe("git", ["rev-parse", "--abbrev-ref", "origin/HEAD"], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 10_000,
|
||||
dryRun: dry,
|
||||
});
|
||||
if (abbrev.ok) {
|
||||
const line = abbrev.value.stdout.trim();
|
||||
if (line.length > 0 && line.includes("/")) {
|
||||
const parts = line.split("/");
|
||||
const last = parts[parts.length - 1];
|
||||
if (last !== undefined && last.length > 0) {
|
||||
logLines.push(`[branch] default via origin/HEAD: ${last}`);
|
||||
return last;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const b of ["main", "master"] as const) {
|
||||
const v = await spawnSafe("git", ["rev-parse", "--verify", `refs/remotes/origin/${b}`], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 10_000,
|
||||
dryRun: dry,
|
||||
});
|
||||
if (v.ok) {
|
||||
logLines.push(`[branch] default fallback: origin/${b} exists`);
|
||||
return b;
|
||||
}
|
||||
}
|
||||
logLines.push("[branch] default fallback: main (no origin/* resolved)");
|
||||
return "main";
|
||||
}
|
||||
|
||||
function appendIoSnippet(logLines: string[], label: string, stdout: string, stderr: string): void {
|
||||
const so = stdout.trim().slice(0, 500);
|
||||
const se = stderr.trim().slice(0, 500);
|
||||
if (so.length > 0) {
|
||||
logLines.push(`${label} stdout (truncated): ${so}`);
|
||||
}
|
||||
if (se.length > 0) {
|
||||
logLines.push(`${label} stderr (truncated): ${se}`);
|
||||
}
|
||||
}
|
||||
|
||||
const workflow: WorkflowDefinition<WorkflowMeta> = {
|
||||
name: "workflow-generator",
|
||||
|
||||
roles: {
|
||||
async analyst(
|
||||
start: StartStep,
|
||||
_messages: WorkflowMessage[],
|
||||
): Promise<RoleResult<WorkflowGenMeta["analyst"]>> {
|
||||
): Promise<RoleResult<WorkflowMeta["analyst"]>> {
|
||||
const dry = isDryRun(start);
|
||||
const userInput = start.content;
|
||||
const empty = emptyAnalystMeta(userInput);
|
||||
@ -368,7 +577,7 @@ Output a thorough analysis in markdown. Do not write final implementation code.`
|
||||
async architect(
|
||||
start: StartStep,
|
||||
messages: WorkflowMessage[],
|
||||
): Promise<RoleResult<WorkflowGenMeta["architect"]>> {
|
||||
): Promise<RoleResult<WorkflowMeta["architect"]>> {
|
||||
const dry = isDryRun(start);
|
||||
if (dry) {
|
||||
return {
|
||||
@ -376,8 +585,13 @@ Output a thorough analysis in markdown. Do not write final implementation code.`
|
||||
meta: { workflowName: "dry-run-test", design: "(dry-run design)" },
|
||||
};
|
||||
}
|
||||
const last = messages[messages.length - 1];
|
||||
const spec = last.meta as WorkflowGenMeta["analyst"];
|
||||
const spec = lastMetaForRole<WorkflowMeta["analyst"]>(messages, "analyst");
|
||||
if (spec === null) {
|
||||
return {
|
||||
content: "Architect skipped — no analyst output in message history.",
|
||||
meta: { workflowName: "", design: "" },
|
||||
};
|
||||
}
|
||||
const wfName = spec.workflowName.trim();
|
||||
|
||||
if (wfName.length === 0) {
|
||||
@ -485,17 +699,21 @@ Output ONLY the design markdown.`;
|
||||
async coder(
|
||||
start: StartStep,
|
||||
messages: WorkflowMessage[],
|
||||
): Promise<RoleResult<WorkflowGenMeta["coder"]>> {
|
||||
): Promise<RoleResult<WorkflowMeta["coder"]>> {
|
||||
const dry = isDryRun(start);
|
||||
if (dry) {
|
||||
return {
|
||||
content: "[dry-run] coder complete",
|
||||
meta: { workflowName: "dry-run-test", generatedFiles: ["(dry-run)"], codegenLog: "(dry-run)" },
|
||||
meta: {
|
||||
workflowName: "dry-run-test",
|
||||
files: { indexTs: false, packageJson: false, tsconfigJson: false },
|
||||
cursorOutput: "(dry-run)",
|
||||
},
|
||||
};
|
||||
}
|
||||
const analystMeta = lastMetaForRole<WorkflowGenMeta["analyst"]>(messages, "analyst");
|
||||
const architectMeta = lastMetaForRole<WorkflowGenMeta["architect"]>(messages, "architect");
|
||||
const priorReviewer = lastMetaForRole<WorkflowGenMeta["reviewer"]>(messages, "reviewer");
|
||||
const analystMeta = lastMetaForRole<WorkflowMeta["analyst"]>(messages, "analyst");
|
||||
const architectMeta = lastMetaForRole<WorkflowMeta["architect"]>(messages, "architect");
|
||||
const priorReviewer = lastMetaForRole<WorkflowMeta["reviewer"]>(messages, "reviewer");
|
||||
|
||||
if (analystMeta === null || architectMeta === null) {
|
||||
return {
|
||||
@ -608,19 +826,39 @@ Implement now.`;
|
||||
async reviewer(
|
||||
start: StartStep,
|
||||
messages: WorkflowMessage[],
|
||||
): Promise<RoleResult<WorkflowGenMeta["reviewer"]>> {
|
||||
): Promise<RoleResult<WorkflowMeta["reviewer"]>> {
|
||||
const dry = isDryRun(start);
|
||||
if (dry) {
|
||||
const attempt = messages.filter((m) => m.role === "reviewer").length + 1;
|
||||
return {
|
||||
content: "[dry-run] reviewer complete — LGTM",
|
||||
meta: { workflowName: "dry-run-test", approved: true, issues: "" },
|
||||
content: "[dry-run] reviewer complete — validation skipped; treating as PASS",
|
||||
meta: {
|
||||
passed: true,
|
||||
workflowName: "dry-run-test",
|
||||
reason: "Dry-run: reviewer validation not executed",
|
||||
attempt,
|
||||
validationLog: "(dry-run)",
|
||||
},
|
||||
};
|
||||
}
|
||||
const last = messages[messages.length - 1];
|
||||
const { workflowName, files } = last.meta as WorkflowGenMeta["coder"];
|
||||
|
||||
const coderEntry = lastMetaForRole<WorkflowMeta["coder"]>(messages, "coder");
|
||||
const attempt = messages.filter((m) => m.role === "reviewer").length + 1;
|
||||
|
||||
if (coderEntry === null) {
|
||||
return {
|
||||
content: "FAIL — no coder message in history",
|
||||
meta: {
|
||||
passed: false,
|
||||
workflowName: "",
|
||||
reason: "Reviewer could not find a prior coder step",
|
||||
attempt,
|
||||
validationLog: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { workflowName, files } = coderEntry;
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!files.indexTs) missing.push("index.ts");
|
||||
if (!files.packageJson) missing.push("package.json");
|
||||
@ -679,6 +917,348 @@ Implement now.`;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async committer(
|
||||
start: StartStep,
|
||||
messages: WorkflowMessage[],
|
||||
): Promise<RoleResult<WorkflowMeta["committer"]>> {
|
||||
const dry = isDryRun(start);
|
||||
const logLines: string[] = [];
|
||||
const gitDir = join(NERVE_ROOT, ".git");
|
||||
const nullMeta = (): WorkflowMeta["committer"] => ({
|
||||
branch: null,
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
skipped: true,
|
||||
error: null,
|
||||
stagedPaths: [],
|
||||
});
|
||||
|
||||
logLines.push("[1] Check `.git` at NERVE_ROOT");
|
||||
if (!existsSync(gitDir)) {
|
||||
logLines.push(`Result: no .git at ${NERVE_ROOT} — skipping all git operations.`);
|
||||
return {
|
||||
content: logLines.join("\n"),
|
||||
meta: nullMeta(),
|
||||
};
|
||||
}
|
||||
|
||||
const analystMeta = lastMetaForRole<WorkflowMeta["analyst"]>(messages, "analyst");
|
||||
const userPrompt = analystMeta?.userPrompt ?? "";
|
||||
const reviewerMeta = lastMetaForRole<WorkflowMeta["reviewer"]>(messages, "reviewer");
|
||||
const coderMeta = lastMetaForRole<WorkflowMeta["coder"]>(messages, "coder");
|
||||
const files =
|
||||
coderMeta !== null
|
||||
? coderMeta.files
|
||||
: { indexTs: false, packageJson: false, tsconfigJson: false };
|
||||
|
||||
const wfName = resolveWorkflowNameForCommitter(messages);
|
||||
if (wfName.length === 0) {
|
||||
logLines.push("ERROR: could not resolve workflowName from analyst/coder/reviewer meta.");
|
||||
return {
|
||||
content: logLines.join("\n"),
|
||||
meta: {
|
||||
branch: null,
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
skipped: false,
|
||||
error: "Empty workflowName — cannot infer paths to stage",
|
||||
stagedPaths: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const includeNerve = await nerveYamlShouldBeStaged(wfName, start);
|
||||
const untracked = await listUntrackedUnderWorkflowDir(wfName, start);
|
||||
const corePaths = buildCoreStagePaths(wfName, files, includeNerve);
|
||||
const plannedSet = new Set<string>(corePaths);
|
||||
for (const u of untracked) {
|
||||
plannedSet.add(u);
|
||||
}
|
||||
const plannedPaths = [...plannedSet].filter(
|
||||
(p) => p === "nerve.yaml" || existsSync(join(NERVE_ROOT, p)),
|
||||
);
|
||||
|
||||
const dryPlanPaths =
|
||||
plannedPaths.length > 0
|
||||
? plannedPaths
|
||||
: buildCoreStagePaths(
|
||||
wfName,
|
||||
{ indexTs: true, packageJson: true, tsconfigJson: true },
|
||||
verifyNerveWorkflowEntry(wfName).ok,
|
||||
);
|
||||
|
||||
if (plannedPaths.length === 0 && !dry) {
|
||||
logLines.push("No candidate paths to `git add` (all file flags false and nerve.yaml not staged).");
|
||||
return {
|
||||
content: logLines.join("\n"),
|
||||
meta: {
|
||||
branch: null,
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
skipped: false,
|
||||
error: "Nothing to stage for this workflow",
|
||||
stagedPaths: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const defaultBranch = await resolveDefaultBranchName(start, logLines);
|
||||
const shortSuffix = Date.now().toString(36);
|
||||
const newBranch = `wf/${sanitizeBranchSegment(wfName)}-${shortSuffix}`;
|
||||
|
||||
if (dry) {
|
||||
logLines.push("[dry-run] Would run: `git checkout <default>` then `git checkout -b " + newBranch + "`");
|
||||
logLines.push(`[dry-run] Would run: \`git add -- ${dryPlanPaths.join(" ")}\``);
|
||||
logLines.push("[dry-run] Would run: `git commit` with message summarizing workflow + user prompt + reviewer");
|
||||
logLines.push(`[dry-run] Would run: \`git push -u origin ${newBranch}\``);
|
||||
return {
|
||||
content: logLines.join("\n"),
|
||||
meta: {
|
||||
branch: newBranch,
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
skipped: false,
|
||||
error: null,
|
||||
stagedPaths: dryPlanPaths,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
logLines.push(`[2] git checkout ${defaultBranch}`);
|
||||
const checkoutBase = await spawnSafe("git", ["checkout", defaultBranch], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 120_000,
|
||||
dryRun: false,
|
||||
});
|
||||
if (!checkoutBase.ok) {
|
||||
const err = formatSpawnFailure(checkoutBase.error);
|
||||
const io = spawnErrorStreams(checkoutBase.error);
|
||||
appendIoSnippet(logLines, "checkout", io.stdout, io.stderr);
|
||||
logLines.push(`ERROR: ${err}`);
|
||||
return {
|
||||
content: logLines.join("\n"),
|
||||
meta: {
|
||||
branch: null,
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
skipped: false,
|
||||
error: `git checkout ${defaultBranch}: ${err}`,
|
||||
stagedPaths: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
appendIoSnippet(logLines, "checkout", checkoutBase.value.stdout, checkoutBase.value.stderr);
|
||||
|
||||
logLines.push(`[3] git checkout -b ${newBranch}`);
|
||||
const checkoutNew = await spawnSafe("git", ["checkout", "-b", newBranch], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 120_000,
|
||||
dryRun: false,
|
||||
});
|
||||
if (!checkoutNew.ok) {
|
||||
const err = formatSpawnFailure(checkoutNew.error);
|
||||
const ioNb = spawnErrorStreams(checkoutNew.error);
|
||||
appendIoSnippet(logLines, "checkout -b", ioNb.stdout, ioNb.stderr);
|
||||
logLines.push(`ERROR: ${err}`);
|
||||
return {
|
||||
content: logLines.join("\n"),
|
||||
meta: {
|
||||
branch: null,
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
skipped: false,
|
||||
error: `git checkout -b: ${err}`,
|
||||
stagedPaths: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
appendIoSnippet(logLines, "checkout -b", checkoutNew.value.stdout, checkoutNew.value.stderr);
|
||||
|
||||
logLines.push(`[4] git add -- ${plannedPaths.join(" ")}`);
|
||||
const addArgs = ["add", "--", ...plannedPaths];
|
||||
const addR = await spawnSafe("git", addArgs, {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 120_000,
|
||||
dryRun: false,
|
||||
});
|
||||
if (!addR.ok) {
|
||||
const err = formatSpawnFailure(addR.error);
|
||||
const ioAdd = spawnErrorStreams(addR.error);
|
||||
appendIoSnippet(logLines, "git add", ioAdd.stdout, ioAdd.stderr);
|
||||
logLines.push(`ERROR: ${err}`);
|
||||
return {
|
||||
content: logLines.join("\n"),
|
||||
meta: {
|
||||
branch: newBranch,
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
skipped: false,
|
||||
error: `git add: ${err}`,
|
||||
stagedPaths: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
appendIoSnippet(logLines, "git add", addR.value.stdout, addR.value.stderr);
|
||||
|
||||
const stagedR = await spawnSafe("git", ["diff", "--cached", "--name-only"], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 30_000,
|
||||
dryRun: false,
|
||||
});
|
||||
const stagedPaths = stagedR.ok
|
||||
? stagedR.value.stdout
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
: [];
|
||||
|
||||
const userBrief = summarizeText(userPrompt, 200);
|
||||
const reasonBrief =
|
||||
reviewerMeta !== null ? summarizeText(reviewerMeta.reason, 240) : "(no reviewer reason)";
|
||||
const subject = `workflow: ${wfName}`;
|
||||
const body =
|
||||
`Workflow: ${wfName}\n` +
|
||||
`User request (summary): ${userBrief}\n` +
|
||||
`Reviewer (summary): ${reasonBrief}\n` +
|
||||
`Staged paths:\n${stagedPaths.map((p) => `- ${p}`).join("\n") || "(none)"}\n`;
|
||||
const commitMessage = `${subject}\n\n${body}`;
|
||||
|
||||
const msgPath = join(tmpdir(), `nerve-workflow-generator-commit-${Date.now()}.txt`);
|
||||
logLines.push(`[5] git commit -F ${msgPath}`);
|
||||
let commitHash: string | null = null;
|
||||
let commitErr: string | null = null;
|
||||
try {
|
||||
writeFileSync(msgPath, commitMessage, "utf-8");
|
||||
const commitR = await spawnSafe("git", ["commit", "-F", msgPath], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 120_000,
|
||||
dryRun: false,
|
||||
});
|
||||
if (!commitR.ok) {
|
||||
commitErr = `git commit: ${formatSpawnFailure(commitR.error)}`;
|
||||
const ioCommit = spawnErrorStreams(commitR.error);
|
||||
appendIoSnippet(logLines, "git commit", ioCommit.stdout, ioCommit.stderr);
|
||||
logLines.push(`ERROR: ${commitErr}`);
|
||||
} else {
|
||||
appendIoSnippet(logLines, "git commit", commitR.value.stdout, commitR.value.stderr);
|
||||
const revR = await spawnSafe("git", ["rev-parse", "HEAD"], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 10_000,
|
||||
dryRun: false,
|
||||
});
|
||||
if (!revR.ok) {
|
||||
commitErr = `git rev-parse HEAD: ${formatSpawnFailure(revR.error)}`;
|
||||
logLines.push(`ERROR: ${commitErr}`);
|
||||
} else {
|
||||
commitHash = revR.value.stdout.trim() || null;
|
||||
logLines.push(`[6] commit hash: ${commitHash ?? "(empty)"}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(msgPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
if (commitErr !== null) {
|
||||
return {
|
||||
content: logLines.filter(Boolean).join("\n"),
|
||||
meta: {
|
||||
branch: newBranch,
|
||||
commitHash,
|
||||
pushed: null,
|
||||
skipped: false,
|
||||
error: commitErr,
|
||||
stagedPaths,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
logLines.push("[7] git remote get-url origin");
|
||||
const urlR = await spawnSafe("git", ["remote", "get-url", "origin"], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 10_000,
|
||||
dryRun: false,
|
||||
});
|
||||
if (!urlR.ok) {
|
||||
logLines.push(`No origin remote (skip push): ${formatSpawnFailure(urlR.error)}`);
|
||||
return {
|
||||
content: logLines.filter(Boolean).join("\n"),
|
||||
meta: {
|
||||
branch: newBranch,
|
||||
commitHash,
|
||||
pushed: null,
|
||||
skipped: false,
|
||||
error: null,
|
||||
stagedPaths,
|
||||
},
|
||||
};
|
||||
}
|
||||
const originUrl = urlR.value.stdout.trim();
|
||||
if (originUrl.length === 0) {
|
||||
logLines.push("origin URL empty — skip push");
|
||||
return {
|
||||
content: logLines.filter(Boolean).join("\n"),
|
||||
meta: {
|
||||
branch: newBranch,
|
||||
commitHash,
|
||||
pushed: null,
|
||||
skipped: false,
|
||||
error: null,
|
||||
stagedPaths,
|
||||
},
|
||||
};
|
||||
}
|
||||
logLines.push(`origin: ${originUrl}`);
|
||||
|
||||
logLines.push(`[8] git push -u origin ${newBranch}`);
|
||||
const pushR = await spawnSafe("git", ["push", "-u", "origin", newBranch], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 300_000,
|
||||
dryRun: false,
|
||||
});
|
||||
if (!pushR.ok) {
|
||||
const pe = `git push: ${formatSpawnFailure(pushR.error)}`;
|
||||
const ioPush = spawnErrorStreams(pushR.error);
|
||||
appendIoSnippet(logLines, "git push", ioPush.stdout, ioPush.stderr);
|
||||
logLines.push(`ERROR: ${pe}`);
|
||||
return {
|
||||
content: logLines.filter(Boolean).join("\n"),
|
||||
meta: {
|
||||
branch: newBranch,
|
||||
commitHash,
|
||||
pushed: false,
|
||||
skipped: false,
|
||||
error: pe,
|
||||
stagedPaths,
|
||||
},
|
||||
};
|
||||
}
|
||||
appendIoSnippet(logLines, "git push", pushR.value.stdout, pushR.value.stderr);
|
||||
|
||||
return {
|
||||
content: logLines.filter(Boolean).join("\n"),
|
||||
meta: {
|
||||
branch: newBranch,
|
||||
commitHash,
|
||||
pushed: true,
|
||||
skipped: false,
|
||||
error: null,
|
||||
stagedPaths,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
moderator(context) {
|
||||
@ -707,15 +1287,19 @@ Implement now.`;
|
||||
}
|
||||
|
||||
if (last.role === "reviewer") {
|
||||
if (last.meta.passed) {
|
||||
return END;
|
||||
if (last.meta.passed === true) {
|
||||
return "committer";
|
||||
}
|
||||
if (last.meta.attempt < 3) {
|
||||
if (last.meta.passed === false && last.meta.attempt < 3) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
}
|
||||
|
||||
if (last.role === "committer") {
|
||||
return END;
|
||||
}
|
||||
|
||||
return END;
|
||||
},
|
||||
};
|
||||
|
||||
@ -9,7 +9,8 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0"
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
|
||||
10
workflows/workflow-generator/pnpm-lock.yaml
generated
10
workflows/workflow-generator/pnpm-lock.yaml
generated
@ -26,12 +26,20 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.17
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
'@types/node@22.19.17':
|
||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
@ -44,6 +52,8 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user