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 interval: 1m
throttle: 15s throttle: 15s
timeout: 5s timeout: 5s
git-workspace-status:
group: workspace
interval: 2m
throttle: 30s
timeout: 15s
workflows: workflows:
sense-generator: sense-generator:

12
pnpm-lock.yaml generated
View File

@ -33,6 +33,18 @@ importers:
specifier: latest specifier: latest
version: 0.31.10 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: senses/hermes-gateway-health:
devDependencies: devDependencies:
'@types/node': '@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 { buildPlannerRole } from "./roles/planner/index.js";
import { buildCoderRole } from "./roles/coder/index.js"; import { buildCoderRole } from "./roles/coder/index.js";
import { buildTesterRole } from "./roles/tester/index.js"; import { buildTesterRole } from "./roles/tester/index.js";
import { buildCommitterRole } 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";
@ -21,6 +22,7 @@ export function buildSenseGenerator({
planner: buildPlannerRole({ provider, cwd }), planner: buildPlannerRole({ provider, cwd }),
coder: buildCoderRole({ provider, cwd }), coder: buildCoderRole({ provider, cwd }),
tester: buildTesterRole({ provider }), tester: buildTesterRole({ provider }),
committer: buildCommitterRole({ nerveRoot: cwd }),
}, },
moderator, moderator,
}; };

View File

@ -3,13 +3,17 @@ import type { Moderator } from "@uncaged/nerve-core";
import type { PlannerMeta } from "./roles/planner/index.js"; import type { PlannerMeta } from "./roles/planner/index.js";
import type { CoderMeta } from "./roles/coder/index.js"; import type { CoderMeta } from "./roles/coder/index.js";
import type { TesterMeta } from "./roles/tester/index.js"; import type { TesterMeta } from "./roles/tester/index.js";
import type { CommitterMeta } from "./roles/committer/index.js";
export type SenseMeta = { export type SenseMeta = {
planner: PlannerMeta; planner: PlannerMeta;
coder: CoderMeta; coder: CoderMeta;
tester: TesterMeta; tester: TesterMeta;
committer: CommitterMeta;
}; };
const MAX_CODER_ITERATIONS = 5;
function countRole(steps: { role: string }[], name: string): number { function countRole(steps: { role: string }[], name: string): number {
return steps.filter((s) => s.role === name).length; 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) => { export const moderator: Moderator<SenseMeta> = (context) => {
if (context.steps.length === 0) return "planner"; if (context.steps.length === 0) return "planner";
const last = context.steps[context.steps.length - 1]; 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 === "planner") return "coder";
if (last.role === "coder") return "tester"; if (last.role === "coder") return "tester";
if (last.role === "tester") { if (last.role === "tester") {
if (last.meta.passed) return END; if (last.meta.passed) return "committer";
return countRole(context.steps, "tester") < 3 ? "coder" : END; 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; 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 ## 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 ## 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: 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 2. Second pass: implement role logic
3. Third pass: fix build/lint errors 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>/index.ts\` — WorkflowDefinition default export
- \`workflows/<name>/build.ts\` — factory function - \`workflows/<name>/build.ts\` — factory function
- \`workflows/<name>/moderator.ts\` — moderator + meta types - \`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>/roles/<role>/prompt.ts\` — prompt pure function
- \`workflows/<name>/package.json\` — with esbuild build script - \`workflows/<name>/package.json\` — with esbuild build script
- \`workflows/<name>/tsconfig.json\` — TypeScript config - \`workflows/<name>/tsconfig.json\` — TypeScript config
- Update \`nerve.yaml\` with \`workflows.<name>\`
For **new workflows**, also update \`nerve.yaml\` with \`workflows.<name>\`.
## Rules ## 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 ## When to return done: true
Return \`done: true\` ONLY when ALL of the following are true: Return \`done: true\` ONLY when ALL of the following are true:
- All required files are created - All changes from the plan are implemented
- \`pnpm install --no-cache && pnpm build\` succeeds (run it!) - \`cd workflows/<name> && pnpm install --no-cache && pnpm build\` succeeds (run it!)
- No lint or type errors remain - 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.`; 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"]); 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) { if (!committed) {
success = false; success = false;
} else { } else {

View File

@ -1,24 +1,33 @@
export function plannerPrompt({ threadId }: { threadId: string }): string { 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 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\` 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: 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.
- Pick a good kebab-case name for this workflow. 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 covering:
- Workflow name
- Roles list (name, purpose, tool)
- Flow transitions / moderator routing logic
- Validation loops design
- External dependencies
- Data flow between roles
If requirements are NOT clear: ## Produce a PLAN (not code) in markdown
- Describe what is missing or ambiguous.
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
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: End your response with a JSON block:
\`\`\`json \`\`\`json

View File

@ -1,5 +1,5 @@
export function testerPrompt({ threadId }: { threadId: string }): string { 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 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\` Read the nerve-dev skill for expected file structure: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`