From 495d8d1b60115b67465725a0b21b500d14d6fe7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Tue, 28 Apr 2026 12:55:08 +0000 Subject: [PATCH] chore(workflow): auto-generated commit --- nerve.yaml | 5 + pnpm-lock.yaml | 12 ++ senses/git-workspace-status/index.js | 122 ++++++++++++++++++ .../migrations/0001_init.sql | 13 ++ senses/git-workspace-status/package.json | 14 ++ senses/git-workspace-status/src/index.ts | 116 +++++++++++++++++ senses/git-workspace-status/src/schema.ts | 13 ++ workflows/sense-generator/build.ts | 2 + workflows/sense-generator/moderator.ts | 16 ++- .../sense-generator/roles/committer/index.ts | 83 ++++++++++++ .../workflow-generator/roles/coder/prompt.ts | 14 +- .../roles/committer/index.ts | 3 +- .../roles/planner/prompt.ts | 37 ++++-- .../workflow-generator/roles/tester/prompt.ts | 2 +- 14 files changed, 428 insertions(+), 24 deletions(-) create mode 100644 senses/git-workspace-status/index.js create mode 100644 senses/git-workspace-status/migrations/0001_init.sql create mode 100644 senses/git-workspace-status/package.json create mode 100644 senses/git-workspace-status/src/index.ts create mode 100644 senses/git-workspace-status/src/schema.ts create mode 100644 workflows/sense-generator/roles/committer/index.ts diff --git a/nerve.yaml b/nerve.yaml index 289f4eb..ccefc5d 100644 --- a/nerve.yaml +++ b/nerve.yaml @@ -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: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88180a1..ea1483b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/senses/git-workspace-status/index.js b/senses/git-workspace-status/index.js new file mode 100644 index 0000000..aeaad38 --- /dev/null +++ b/senses/git-workspace-status/index.js @@ -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 +}; diff --git a/senses/git-workspace-status/migrations/0001_init.sql b/senses/git-workspace-status/migrations/0001_init.sql new file mode 100644 index 0000000..5b090ce --- /dev/null +++ b/senses/git-workspace-status/migrations/0001_init.sql @@ -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 +); diff --git a/senses/git-workspace-status/package.json b/senses/git-workspace-status/package.json new file mode 100644 index 0000000..2ec7b90 --- /dev/null +++ b/senses/git-workspace-status/package.json @@ -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" + } +} diff --git a/senses/git-workspace-status/src/index.ts b/senses/git-workspace-status/src/index.ts new file mode 100644 index 0000000..9067898 --- /dev/null +++ b/senses/git-workspace-status/src/index.ts @@ -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, + }; +} diff --git a/senses/git-workspace-status/src/schema.ts b/senses/git-workspace-status/src/schema.ts new file mode 100644 index 0000000..b39de2c --- /dev/null +++ b/senses/git-workspace-status/src/schema.ts @@ -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(), +}); diff --git a/workflows/sense-generator/build.ts b/workflows/sense-generator/build.ts index 2ca74f7..ddc6372 100644 --- a/workflows/sense-generator/build.ts +++ b/workflows/sense-generator/build.ts @@ -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, }; diff --git a/workflows/sense-generator/moderator.ts b/workflows/sense-generator/moderator.ts index 567881b..c3e6e47 100644 --- a/workflows/sense-generator/moderator.ts +++ b/workflows/sense-generator/moderator.ts @@ -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 = (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; }; diff --git a/workflows/sense-generator/roles/committer/index.ts b/workflows/sense-generator/roles/committer/index.ts new file mode 100644 index 0000000..ee8dc91 --- /dev/null +++ b/workflows/sense-generator/roles/committer/index.ts @@ -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 { + 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; + } + + const lines: string[] = []; + let success = true; + + const run = async (cmd: string, args: string[]): Promise => { + 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; + }; +} diff --git a/workflows/workflow-generator/roles/coder/prompt.ts b/workflows/workflow-generator/roles/coder/prompt.ts index 643b155..84e65f1 100644 --- a/workflows/workflow-generator/roles/coder/prompt.ts +++ b/workflows/workflow-generator/roles/coder/prompt.ts @@ -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//index.ts\` — WorkflowDefinition default export - \`workflows//build.ts\` — factory function - \`workflows//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//roles//prompt.ts\` — prompt pure function - \`workflows//package.json\` — with esbuild build script - \`workflows//tsconfig.json\` — TypeScript config -- Update \`nerve.yaml\` with \`workflows.\` + +For **new workflows**, also update \`nerve.yaml\` with \`workflows.\`. ## 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/ && 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.`; diff --git a/workflows/workflow-generator/roles/committer/index.ts b/workflows/workflow-generator/roles/committer/index.ts index 2e6624b..1d6a989 100644 --- a/workflows/workflow-generator/roles/committer/index.ts +++ b/workflows/workflow-generator/roles/committer/index.ts @@ -63,7 +63,8 @@ export function buildCommitterRole({ nerveRoot }: BuildCommitterDeps): Role/moderator.ts\`, \`cat workflows//build.ts\`, \`ls workflows//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. -If requirements are NOT clear: -- Describe what is missing or ambiguous. +## 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 + +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 diff --git a/workflows/workflow-generator/roles/tester/prompt.ts b/workflows/workflow-generator/roles/tester/prompt.ts index 042744e..749e416 100644 --- a/workflows/workflow-generator/roles/tester/prompt.ts +++ b/workflows/workflow-generator/roles/tester/prompt.ts @@ -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\`