chore(workflow): auto-generated commit

This commit is contained in:
小橘 2026-04-28 12:55:08 +00:00
parent 0fab8a68c3
commit 495d8d1b60
14 changed files with 428 additions and 24 deletions

View File

@ -20,6 +20,11 @@ senses:
interval: 1m
throttle: 15s
timeout: 5s
git-workspace-status:
group: workspace
interval: 2m
throttle: 30s
timeout: 15s
workflows:
sense-generator:

12
pnpm-lock.yaml generated
View File

@ -33,6 +33,18 @@ importers:
specifier: latest
version: 0.31.10
senses/git-workspace-status:
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
senses/hermes-gateway-health:
devDependencies:
'@types/node':

View File

@ -0,0 +1,122 @@
// src/index.ts
import { execFileSync } from "node:child_process";
import { resolve } from "node:path";
// src/schema.ts
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
var snapshots = sqliteTable("snapshots", {
ts: integer("ts").primaryKey(),
branch: text("branch").notNull(),
headShort: text("head_short").notNull(),
porcelainLines: integer("porcelain_lines").notNull(),
hasUpstream: integer("has_upstream").notNull(),
aheadCount: integer("ahead_count").notNull(),
behindCount: integer("behind_count").notNull(),
/** Empty string when the snapshot succeeded; otherwise a short error summary. */
gitError: text("git_error").notNull()
});
// src/index.ts
var GIT_TIMEOUT_MS = 15e3;
function workspaceRoot() {
const raw = process.env.GIT_WORKSPACE_ROOT;
return raw ? resolve(raw) : resolve(process.cwd());
}
function gitErrorMessage(err) {
if (err instanceof Error) {
const m = err.message.trim();
return m.length > 200 ? `${m.slice(0, 197)}...` : m;
}
return String(err);
}
function runGit(cwd, args) {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: 2 * 1024 * 1024
}).trimEnd();
}
function countPorcelainLines(output) {
if (!output) return 0;
return output.split("\n").filter((line) => line.length > 0).length;
}
async function compute(db, _peers) {
const root = workspaceRoot();
const ts = Date.now();
let branch = "";
let headShort = "";
let porcelainLines = 0;
let hasUpstream = 0;
let aheadCount = 0;
let behindCount = 0;
let gitError = "";
try {
const inside = runGit(root, ["rev-parse", "--is-inside-work-tree"]).trim();
if (inside !== "true") {
gitError = "not a git work tree";
await db.insert(snapshots).values({
ts,
branch,
headShort,
porcelainLines,
hasUpstream,
aheadCount,
behindCount,
gitError
});
return {
workspaceRoot: root,
branch,
headShort,
porcelainLines,
hasUpstream: false,
aheadCount,
behindCount,
gitError
};
}
branch = runGit(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
headShort = runGit(root, ["rev-parse", "--short", "HEAD"]);
porcelainLines = countPorcelainLines(runGit(root, ["status", "--porcelain"]));
try {
runGit(root, ["rev-parse", "--abbrev-ref", "@{upstream}"]);
hasUpstream = 1;
const lb = runGit(root, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
const parts = lb.split(/[\t\s]+/).filter(Boolean);
if (parts.length >= 2) {
aheadCount = Number.parseInt(parts[0], 10) || 0;
behindCount = Number.parseInt(parts[1], 10) || 0;
}
} catch {
hasUpstream = 0;
aheadCount = 0;
behindCount = 0;
}
} catch (e) {
gitError = gitErrorMessage(e);
}
await db.insert(snapshots).values({
ts,
branch,
headShort,
porcelainLines,
hasUpstream,
aheadCount,
behindCount,
gitError
});
return {
workspaceRoot: root,
branch,
headShort,
porcelainLines,
hasUpstream: hasUpstream === 1,
aheadCount,
behindCount,
gitError: gitError || void 0
};
}
export {
compute
};

View File

@ -0,0 +1,13 @@
-- Migration: 0001_init
-- Creates the snapshots table for git-workspace-status sense.
CREATE TABLE IF NOT EXISTS snapshots (
ts INTEGER PRIMARY KEY,
branch TEXT NOT NULL,
head_short TEXT NOT NULL,
porcelain_lines INTEGER NOT NULL,
has_upstream INTEGER NOT NULL,
ahead_count INTEGER NOT NULL,
behind_count INTEGER NOT NULL,
git_error TEXT NOT NULL
);

View File

@ -0,0 +1,14 @@
{
"name": "sense-git-workspace-status",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=index.js --packages=external"
},
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.27.0",
"typescript": "^5.7.0"
}
}

View File

@ -0,0 +1,116 @@
import { execFileSync } from "node:child_process";
import { resolve } from "node:path";
import type { LibSQLDatabase } from "drizzle-orm/libsql";
import { snapshots } from "./schema.ts";
const GIT_TIMEOUT_MS = 15_000;
function workspaceRoot(): string {
const raw = process.env.GIT_WORKSPACE_ROOT;
return raw ? resolve(raw) : resolve(process.cwd());
}
function gitErrorMessage(err: unknown): string {
if (err instanceof Error) {
const m = err.message.trim();
return m.length > 200 ? `${m.slice(0, 197)}...` : m;
}
return String(err);
}
function runGit(cwd: string, args: string[]): string {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: 2 * 1024 * 1024,
}).trimEnd();
}
function countPorcelainLines(output: string): number {
if (!output) return 0;
return output.split("\n").filter((line) => line.length > 0).length;
}
export async function compute(db: LibSQLDatabase, _peers: unknown) {
const root = workspaceRoot();
const ts = Date.now();
let branch = "";
let headShort = "";
let porcelainLines = 0;
let hasUpstream = 0;
let aheadCount = 0;
let behindCount = 0;
let gitError = "";
try {
const inside = runGit(root, ["rev-parse", "--is-inside-work-tree"]).trim();
if (inside !== "true") {
gitError = "not a git work tree";
await db.insert(snapshots).values({
ts,
branch,
headShort,
porcelainLines,
hasUpstream,
aheadCount,
behindCount,
gitError,
});
return {
workspaceRoot: root,
branch,
headShort,
porcelainLines,
hasUpstream: false,
aheadCount,
behindCount,
gitError,
};
}
branch = runGit(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
headShort = runGit(root, ["rev-parse", "--short", "HEAD"]);
porcelainLines = countPorcelainLines(runGit(root, ["status", "--porcelain"]));
try {
runGit(root, ["rev-parse", "--abbrev-ref", "@{upstream}"]);
hasUpstream = 1;
const lb = runGit(root, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
const parts = lb.split(/[\t\s]+/).filter(Boolean);
if (parts.length >= 2) {
aheadCount = Number.parseInt(parts[0], 10) || 0;
behindCount = Number.parseInt(parts[1], 10) || 0;
}
} catch {
hasUpstream = 0;
aheadCount = 0;
behindCount = 0;
}
} catch (e) {
gitError = gitErrorMessage(e);
}
await db.insert(snapshots).values({
ts,
branch,
headShort,
porcelainLines,
hasUpstream,
aheadCount,
behindCount,
gitError,
});
return {
workspaceRoot: root,
branch,
headShort,
porcelainLines,
hasUpstream: hasUpstream === 1,
aheadCount,
behindCount,
gitError: gitError || undefined,
};
}

View File

@ -0,0 +1,13 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const snapshots = sqliteTable("snapshots", {
ts: integer("ts").primaryKey(),
branch: text("branch").notNull(),
headShort: text("head_short").notNull(),
porcelainLines: integer("porcelain_lines").notNull(),
hasUpstream: integer("has_upstream").notNull(),
aheadCount: integer("ahead_count").notNull(),
behindCount: integer("behind_count").notNull(),
/** Empty string when the snapshot succeeded; otherwise a short error summary. */
gitError: text("git_error").notNull(),
});

View File

@ -3,6 +3,7 @@ import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { buildPlannerRole } from "./roles/planner/index.js";
import { buildCoderRole } from "./roles/coder/index.js";
import { buildTesterRole } from "./roles/tester/index.js";
import { buildCommitterRole } from "./roles/committer/index.js";
import { moderator } from "./moderator.js";
import type { SenseMeta } from "./moderator.js";
@ -21,6 +22,7 @@ export function buildSenseGenerator({
planner: buildPlannerRole({ provider, cwd }),
coder: buildCoderRole({ provider, cwd }),
tester: buildTesterRole({ provider }),
committer: buildCommitterRole({ nerveRoot: cwd }),
},
moderator,
};

View File

@ -3,13 +3,17 @@ import type { Moderator } from "@uncaged/nerve-core";
import type { PlannerMeta } from "./roles/planner/index.js";
import type { CoderMeta } from "./roles/coder/index.js";
import type { TesterMeta } from "./roles/tester/index.js";
import type { CommitterMeta } from "./roles/committer/index.js";
export type SenseMeta = {
planner: PlannerMeta;
coder: CoderMeta;
tester: TesterMeta;
committer: CommitterMeta;
};
const MAX_CODER_ITERATIONS = 5;
function countRole(steps: { role: string }[], name: string): number {
return steps.filter((s) => s.role === name).length;
}
@ -17,11 +21,19 @@ function countRole(steps: { role: string }[], name: string): number {
export const moderator: Moderator<SenseMeta> = (context) => {
if (context.steps.length === 0) return "planner";
const last = context.steps[context.steps.length - 1];
const coderCount = context.steps.filter((s) => s.role === "coder").length;
if (last.role === "planner") return "coder";
if (last.role === "coder") return "tester";
if (last.role === "tester") {
if (last.meta.passed) return END;
return countRole(context.steps, "tester") < 3 ? "coder" : END;
if (last.meta.passed) return "committer";
const testerCount = countRole(context.steps, "tester");
if (testerCount < 3 && coderCount < MAX_CODER_ITERATIONS) return "coder";
return END;
}
if (last.role === "committer") {
if (last.meta.success) return END;
return coderCount < MAX_CODER_ITERATIONS ? "coder" : END;
}
return END;
};

View File

@ -0,0 +1,83 @@
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";
export type CommitterMeta = {
success: boolean;
};
export type BuildCommitterDeps = {
nerveRoot: string;
};
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 });
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 {
lines.push(e.message);
}
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;
}
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>;
};
}

View File

@ -5,17 +5,18 @@ Also look at existing workflows in the \`workflows/\` directory for patterns.
## Your task
Implement (or fix) the workflow the planner designed. If there is tester or committer feedback in the thread, fix the issues they identified.
Implement the planner's design. This may be **creating a new workflow** or **modifying an existing one**. If there is tester or committer feedback in the thread, fix the issues they identified.
## Multi-step approach
You do NOT need to finish everything in one pass. You may return \`done: false\` to continue in the next iteration. For example:
1. First pass: scaffold files and basic structure
1. First pass: scaffold files / make structural changes
2. Second pass: implement role logic
3. Third pass: fix build/lint errors
## Required files for each workflow
## Workflow file structure
Each workflow must have:
- \`workflows/<name>/index.ts\` — WorkflowDefinition default export
- \`workflows/<name>/build.ts\` — factory function
- \`workflows/<name>/moderator.ts\` — moderator + meta types
@ -23,7 +24,8 @@ You do NOT need to finish everything in one pass. You may return \`done: false\`
- \`workflows/<name>/roles/<role>/prompt.ts\` — prompt pure function
- \`workflows/<name>/package.json\` — with esbuild build script
- \`workflows/<name>/tsconfig.json\` — TypeScript config
- Update \`nerve.yaml\` with \`workflows.<name>\`
For **new workflows**, also update \`nerve.yaml\` with \`workflows.<name>\`.
## Rules
@ -36,8 +38,8 @@ You do NOT need to finish everything in one pass. You may return \`done: false\`
## When to return done: true
Return \`done: true\` ONLY when ALL of the following are true:
- All required files are created
- \`pnpm install --no-cache && pnpm build\` succeeds (run it!)
- All changes from the plan are implemented
- \`cd workflows/<name> && pnpm install --no-cache && pnpm build\` succeeds (run it!)
- No lint or type errors remain
Return \`done: false\` if you made progress but there is still work to do, or if build/lint has errors you plan to fix in the next iteration.`;

View File

@ -63,7 +63,8 @@ export function buildCommitterRole({ nerveRoot }: BuildCommitterDeps): Role<Comm
};
await run("git", ["add", "-A"]);
const committed = await run("git", ["commit", "-m", "chore: add generated workflow"]);
// 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 {

View File

@ -1,24 +1,33 @@
export function plannerPrompt({ threadId }: { threadId: string }): string {
return `You are planning a new Nerve workflow.
return `You are a Nerve workflow planner. You can **create new workflows** or **modify existing ones**.
Read the workflow thread for the user's request: \`nerve thread ${threadId}\`
Read the nerve-dev skill for workflow conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
Also look at existing workflows in the \`workflows/\` directory for patterns.
List existing workflows: \`ls workflows/\`
Assess whether the requirements are clear enough to implement a workflow.
## Determine the task type
If requirements ARE clear:
- Pick a good kebab-case name for this workflow.
- Produce a PLAN (not code) in markdown covering:
- Workflow name
1. If the user wants to **modify an existing workflow** read its current code (\`cat workflows/<name>/moderator.ts\`, \`cat workflows/<name>/build.ts\`, \`ls workflows/<name>/roles/\`, etc.) and understand its current structure before planning changes.
2. If the user wants to **create a new workflow** look at existing workflows in \`workflows/\` for patterns to follow.
## Produce a PLAN (not code) in markdown
For **new workflows**:
- Workflow name (kebab-case)
- Roles list (name, purpose, tool)
- Flow transitions / moderator routing logic
- Validation loops design
- External dependencies
- Data flow between roles
If requirements are NOT clear:
- Describe what is missing or ambiguous.
For **modifications to existing workflows**:
- Workflow name (existing)
- What changes are needed and why
- Files to add/modify/delete
- Impact on moderator routing logic
- Backward compatibility considerations (if any)
If requirements are NOT clear, describe what is missing or ambiguous.
End your response with a JSON block:
\`\`\`json

View File

@ -1,5 +1,5 @@
export function testerPrompt({ threadId }: { threadId: string }): string {
return `You are testing a newly generated Nerve workflow end-to-end.
return `You are testing a Nerve workflow — either newly created or recently modified.
Read the workflow thread for context: \`nerve thread ${threadId}\`
Read the nerve-dev skill for expected file structure: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`