refactor: rename workflows to verb phrases, delete gitea-issue-solver
- workflow-generator → generate-workflow
- sense-generator → generate-sense
- Delete gitea-issue-solver (replaced by solve-issue)
小橘 🍊(NEKO Team)
This commit is contained in:
parent
7313111548
commit
86f02da306
@ -27,13 +27,10 @@ senses:
|
|||||||
timeout: 15s
|
timeout: 15s
|
||||||
|
|
||||||
workflows:
|
workflows:
|
||||||
sense-generator:
|
generate-sense:
|
||||||
concurrency: 1
|
concurrency: 1
|
||||||
overflow: drop
|
overflow: drop
|
||||||
workflow-generator:
|
generate-workflow:
|
||||||
concurrency: 1
|
|
||||||
overflow: drop
|
|
||||||
gitea-issue-solver:
|
|
||||||
concurrency: 1
|
concurrency: 1
|
||||||
overflow: drop
|
overflow: drop
|
||||||
solve-issue:
|
solve-issue:
|
||||||
|
|||||||
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@ -93,7 +93,7 @@ importers:
|
|||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
workflows/gitea-issue-solver:
|
workflows/generate-sense:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@uncaged/nerve-core':
|
'@uncaged/nerve-core':
|
||||||
specifier: link:../../../repos/nerve/packages/core
|
specifier: link:../../../repos/nerve/packages/core
|
||||||
@ -115,7 +115,7 @@ importers:
|
|||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
workflows/sense-generator:
|
workflows/generate-workflow:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@uncaged/nerve-core':
|
'@uncaged/nerve-core':
|
||||||
specifier: link:../../../repos/nerve/packages/core
|
specifier: link:../../../repos/nerve/packages/core
|
||||||
@ -159,28 +159,6 @@ importers:
|
|||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
workflows/workflow-generator:
|
|
||||||
dependencies:
|
|
||||||
'@uncaged/nerve-core':
|
|
||||||
specifier: link:../../../repos/nerve/packages/core
|
|
||||||
version: link:../../../repos/nerve/packages/core
|
|
||||||
'@uncaged/nerve-workflow-utils':
|
|
||||||
specifier: link:../../../repos/nerve/packages/workflow-utils
|
|
||||||
version: link:../../../repos/nerve/packages/workflow-utils
|
|
||||||
zod:
|
|
||||||
specifier: ^4.3.6
|
|
||||||
version: 4.3.6
|
|
||||||
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
|
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@drizzle-team/brocli@0.10.2':
|
'@drizzle-team/brocli@0.10.2':
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export function buildSenseGenerator({
|
|||||||
cwd,
|
cwd,
|
||||||
}: BuildSenseGeneratorDeps): WorkflowDefinition<SenseMeta> {
|
}: BuildSenseGeneratorDeps): WorkflowDefinition<SenseMeta> {
|
||||||
return {
|
return {
|
||||||
name: "sense-generator",
|
name: "generate-sense",
|
||||||
roles: {
|
roles: {
|
||||||
planner: buildPlannerRole({ provider, cwd }),
|
planner: buildPlannerRole({ provider, cwd }),
|
||||||
coder: buildCoderRole({ provider, cwd }),
|
coder: buildCoderRole({ provider, cwd }),
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "sense-generator-workflow",
|
"name": "generate-sense-workflow",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@ -19,7 +19,7 @@ export function buildWorkflowGenerator({
|
|||||||
nerveRoot,
|
nerveRoot,
|
||||||
}: BuildWorkflowGeneratorDeps): WorkflowDefinition<WorkflowMeta> {
|
}: BuildWorkflowGeneratorDeps): WorkflowDefinition<WorkflowMeta> {
|
||||||
return {
|
return {
|
||||||
name: "workflow-generator",
|
name: "generate-workflow",
|
||||||
roles: {
|
roles: {
|
||||||
planner: buildPlannerRole({ provider, cwd: nerveRoot }),
|
planner: buildPlannerRole({ provider, cwd: nerveRoot }),
|
||||||
coder: buildCoderRole({ provider, cwd: nerveRoot }),
|
coder: buildCoderRole({ provider, cwd: nerveRoot }),
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "workflow-generator-workflow",
|
"name": "generate-workflow-workflow",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import type { WorkflowDefinition } from "@uncaged/nerve-core";
|
|
||||||
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
|
|
||||||
import { moderator } from "./moderator.js";
|
|
||||||
import type { WorkflowMeta } from "./moderator.js";
|
|
||||||
import { buildIntakeRole } from "./roles/intake/index.js";
|
|
||||||
import { buildIssueReaderRole } from "./roles/issue-reader/index.js";
|
|
||||||
import { buildPlannerRole } from "./roles/planner/index.js";
|
|
||||||
import { buildImplementerRole } from "./roles/implementer/index.js";
|
|
||||||
import { buildReviewerRole } from "./roles/reviewer/index.js";
|
|
||||||
import { buildTesterRole } from "./roles/tester/index.js";
|
|
||||||
import { buildPrPublisherRole } from "./roles/pr-publisher/index.js";
|
|
||||||
|
|
||||||
export type BuildGiteaIssueSolverDeps = {
|
|
||||||
nerveRoot: string;
|
|
||||||
provider: LlmProvider | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildGiteaIssueSolver({ nerveRoot, provider }: BuildGiteaIssueSolverDeps): WorkflowDefinition<WorkflowMeta> {
|
|
||||||
return {
|
|
||||||
name: "gitea-issue-solver",
|
|
||||||
roles: {
|
|
||||||
intake: buildIntakeRole(),
|
|
||||||
"issue-reader": buildIssueReaderRole({ nerveRoot }),
|
|
||||||
planner: buildPlannerRole({ nerveRoot }),
|
|
||||||
implementer: buildImplementerRole({ nerveRoot }),
|
|
||||||
reviewer: provider ? buildReviewerRole({ provider, nerveRoot }) : buildReviewerRole({ provider: { apiKey: "", baseUrl: "", model: "" }, nerveRoot }),
|
|
||||||
tester: buildTesterRole({ nerveRoot }),
|
|
||||||
"pr-publisher": buildPrPublisherRole({ nerveRoot }),
|
|
||||||
},
|
|
||||||
moderator,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import { join } from "node:path";
|
|
||||||
import { buildGiteaIssueSolver } from "./build.js";
|
|
||||||
import { resolveDashScopeProvider } from "./lib/provider.js";
|
|
||||||
|
|
||||||
const HOME = process.env.HOME ?? "/home/azureuser";
|
|
||||||
const NERVE_ROOT = join(HOME, ".uncaged-nerve");
|
|
||||||
|
|
||||||
const provider = await resolveDashScopeProvider(NERVE_ROOT);
|
|
||||||
|
|
||||||
const workflow = buildGiteaIssueSolver({ nerveRoot: NERVE_ROOT, provider });
|
|
||||||
|
|
||||||
export default workflow;
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
export const ISSUE_READER_MAX_ATTEMPTS = 3;
|
|
||||||
export const TESTER_MAX_ATTEMPTS = 3;
|
|
||||||
export const PUBLISHER_MAX_ATTEMPTS = 3;
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import type { WorkflowMessage } from "@uncaged/nerve-core";
|
|
||||||
|
|
||||||
export function lastMetaForRole<M>(messages: WorkflowMessage[], role: string): M | null {
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
|
||||||
if (messages[i].role === role) {
|
|
||||||
return messages[i].meta as M;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import { readNerveYaml } from "@uncaged/nerve-workflow-utils";
|
|
||||||
|
|
||||||
export function readNerveConfigText(nerveRoot: string): string {
|
|
||||||
const result = readNerveYaml({ nerveRoot });
|
|
||||||
return result.ok ? result.value : "# nerve.yaml unavailable";
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import { spawnSafe } from "@uncaged/nerve-workflow-utils";
|
|
||||||
|
|
||||||
export type Provider = {
|
|
||||||
baseUrl: string;
|
|
||||||
apiKey: string;
|
|
||||||
model: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function cfgGet(nerveRoot: string, key: string): Promise<string | null> {
|
|
||||||
const result = await spawnSafe("cfg", ["get", key], { cwd: nerveRoot, env: null, timeoutMs: 10_000 });
|
|
||||||
if (!result.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const value = result.value.stdout.trim();
|
|
||||||
return value.length > 0 ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveDashScopeProvider(nerveRoot: string): Promise<Provider | null> {
|
|
||||||
const apiKey = process.env.DASHSCOPE_API_KEY ?? (await cfgGet(nerveRoot, "DASHSCOPE_API_KEY"));
|
|
||||||
const baseUrl = process.env.DASHSCOPE_BASE_URL ?? (await cfgGet(nerveRoot, "DASHSCOPE_BASE_URL"));
|
|
||||||
const model = process.env.DASHSCOPE_MODEL ?? (await cfgGet(nerveRoot, "DASHSCOPE_MODEL")) ?? "qwen-plus";
|
|
||||||
if (!apiKey || !baseUrl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { apiKey, baseUrl, model };
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import { spawnSafe } from "@uncaged/nerve-workflow-utils";
|
|
||||||
import { formatSpawnFailure } from "./spawn-utils.js";
|
|
||||||
|
|
||||||
export async function runTestCommand(
|
|
||||||
nerveRoot: string,
|
|
||||||
command: string,
|
|
||||||
): Promise<{
|
|
||||||
ok: boolean;
|
|
||||||
stdoutPreview: string;
|
|
||||||
stderrPreview: string;
|
|
||||||
reason: string | null;
|
|
||||||
}> {
|
|
||||||
const result = await spawnSafe("bash", ["-lc", command], {
|
|
||||||
cwd: nerveRoot,
|
|
||||||
env: null,
|
|
||||||
timeoutMs: 300_000,
|
|
||||||
});
|
|
||||||
if (result.ok) {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
stdoutPreview: result.value.stdout.slice(0, 600),
|
|
||||||
stderrPreview: result.value.stderr.slice(0, 400),
|
|
||||||
reason: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
stdoutPreview: (result.error.kind === "spawn_failed" ? "" : result.error.stdout).slice(0, 600),
|
|
||||||
stderrPreview: (result.error.kind === "spawn_failed" ? result.error.message : result.error.stderr).slice(0, 400),
|
|
||||||
reason: formatSpawnFailure(result.error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
|
|
||||||
|
|
||||||
export async function runWithRetries(
|
|
||||||
run: () => Promise<
|
|
||||||
| { ok: true; stdout: string; stderr: string }
|
|
||||||
| { ok: false; error: SpawnError; lastStdout: string; lastStderr: string }
|
|
||||||
>,
|
|
||||||
maxAttempts: number,
|
|
||||||
): Promise<
|
|
||||||
| { ok: true; stdout: string; stderr: string; attemptsUsed: number }
|
|
||||||
| { ok: false; error: SpawnError; attemptsUsed: number; lastStdout: string; lastStderr: string }
|
|
||||||
> {
|
|
||||||
let attemptsUsed = 0;
|
|
||||||
let lastError: SpawnError | null = null;
|
|
||||||
let lastStdout = "";
|
|
||||||
let lastStderr = "";
|
|
||||||
while (attemptsUsed < maxAttempts) {
|
|
||||||
attemptsUsed += 1;
|
|
||||||
const current = await run();
|
|
||||||
if (current.ok) {
|
|
||||||
return { ok: true, stdout: current.stdout, stderr: current.stderr, attemptsUsed };
|
|
||||||
}
|
|
||||||
lastError = current.error;
|
|
||||||
lastStdout = current.lastStdout;
|
|
||||||
lastStderr = current.lastStderr;
|
|
||||||
if (attemptsUsed < maxAttempts) {
|
|
||||||
const backoffMs = Math.min(1000 * 2 ** (attemptsUsed - 1), 4000);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lastError === null) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: { kind: "spawn_failed", message: "unknown retry failure" },
|
|
||||||
attemptsUsed,
|
|
||||||
lastStdout,
|
|
||||||
lastStderr,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { ok: false, error: lastError, attemptsUsed, lastStdout, lastStderr };
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
|
|
||||||
|
|
||||||
export function formatSpawnFailure(error: SpawnError): string {
|
|
||||||
if (error.kind === "spawn_failed") {
|
|
||||||
return `spawn_failed: ${error.message}`;
|
|
||||||
}
|
|
||||||
if (error.kind === "timeout") {
|
|
||||||
return `timeout: stdout=${error.stdout.slice(0, 200)} stderr=${error.stderr.slice(0, 200)}`;
|
|
||||||
}
|
|
||||||
return `non_zero_exit(${error.exitCode}): stderr=${error.stderr.slice(0, 400)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function errorText(error: SpawnError): string {
|
|
||||||
if (error.kind === "spawn_failed") {
|
|
||||||
return error.message.toLowerCase();
|
|
||||||
}
|
|
||||||
if (error.kind === "timeout") {
|
|
||||||
return `${error.stdout} ${error.stderr}`.toLowerCase();
|
|
||||||
}
|
|
||||||
return `${error.stdout} ${error.stderr}`.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function classifyIssueReaderFailure(error: SpawnError): {
|
|
||||||
transientError: boolean;
|
|
||||||
failureKind: "transient" | "permanent";
|
|
||||||
} {
|
|
||||||
if (error.kind === "timeout" || error.kind === "spawn_failed") {
|
|
||||||
return { transientError: true, failureKind: "transient" };
|
|
||||||
}
|
|
||||||
const text = errorText(error);
|
|
||||||
if (
|
|
||||||
text.includes("network") ||
|
|
||||||
text.includes("timed out") ||
|
|
||||||
text.includes("timeout") ||
|
|
||||||
text.includes("connection reset") ||
|
|
||||||
text.includes("connection refused") ||
|
|
||||||
text.includes("429") ||
|
|
||||||
text.includes("500") ||
|
|
||||||
text.includes("502") ||
|
|
||||||
text.includes("503") ||
|
|
||||||
text.includes("504")
|
|
||||||
) {
|
|
||||||
return { transientError: true, failureKind: "transient" };
|
|
||||||
}
|
|
||||||
return { transientError: false, failureKind: "permanent" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function classifyPublisherFailure(error: SpawnError): "auth_or_network" | "permanent" {
|
|
||||||
if (error.kind === "timeout" || error.kind === "spawn_failed") {
|
|
||||||
return "auth_or_network";
|
|
||||||
}
|
|
||||||
const text = errorText(error);
|
|
||||||
if (
|
|
||||||
text.includes("401") ||
|
|
||||||
text.includes("403") ||
|
|
||||||
text.includes("auth") ||
|
|
||||||
text.includes("token") ||
|
|
||||||
text.includes("permission") ||
|
|
||||||
text.includes("network") ||
|
|
||||||
text.includes("connection") ||
|
|
||||||
text.includes("timeout")
|
|
||||||
) {
|
|
||||||
return "auth_or_network";
|
|
||||||
}
|
|
||||||
return "permanent";
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
export function slugify(input: string): string {
|
|
||||||
const normalized = input
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+|-+$/g, "");
|
|
||||||
return normalized.length > 0 ? normalized.slice(0, 40) : "update";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractPrInfo(text: string): { prUrl: string | null; prNumber: number | null } {
|
|
||||||
const url = text.match(/https?:\/\/\S+/)?.[0] ?? null;
|
|
||||||
const num = text.match(/(?:pulls|pull|pr)\/(\d+)/i)?.[1] ?? text.match(/#(\d+)/)?.[1] ?? null;
|
|
||||||
return { prUrl: url, prNumber: num === null ? null : Number(num) };
|
|
||||||
}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
import { END } from "@uncaged/nerve-core";
|
|
||||||
import type { Moderator } from "@uncaged/nerve-core";
|
|
||||||
import type { IntakeMeta } from "./roles/intake/index.js";
|
|
||||||
import type { IssueReaderMeta } from "./roles/issue-reader/index.js";
|
|
||||||
import type { PlannerMeta } from "./roles/planner/index.js";
|
|
||||||
import type { ImplementerMeta } from "./roles/implementer/index.js";
|
|
||||||
import type { ReviewerMeta } from "./roles/reviewer/index.js";
|
|
||||||
import type { TesterMeta } from "./roles/tester/index.js";
|
|
||||||
import type { PrPublisherMeta } from "./roles/pr-publisher/index.js";
|
|
||||||
|
|
||||||
export type WorkflowMeta = {
|
|
||||||
intake: IntakeMeta;
|
|
||||||
"issue-reader": IssueReaderMeta;
|
|
||||||
planner: PlannerMeta;
|
|
||||||
implementer: ImplementerMeta;
|
|
||||||
reviewer: ReviewerMeta;
|
|
||||||
tester: TesterMeta;
|
|
||||||
"pr-publisher": PrPublisherMeta;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAX_IMPLEMENTER_ROUNDS = 20;
|
|
||||||
const MAX_TOTAL_REJECTIONS = 10;
|
|
||||||
|
|
||||||
function implementerRounds(steps: { role: string }[]): number {
|
|
||||||
return steps.filter((s) => s.role === "implementer").length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function totalRejections(steps: { role: string; meta: unknown }[]): number {
|
|
||||||
return steps.filter((s) => {
|
|
||||||
if (s.role === "reviewer") return !(s.meta as Record<string, boolean>).approved;
|
|
||||||
if (s.role === "tester") return !(s.meta as Record<string, boolean>).passed;
|
|
||||||
return false;
|
|
||||||
}).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function canRetryImplementer(steps: { role: string; meta: unknown }[]): boolean {
|
|
||||||
return implementerRounds(steps) < MAX_IMPLEMENTER_ROUNDS && totalRejections(steps) < MAX_TOTAL_REJECTIONS;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const moderator: Moderator<WorkflowMeta> = (context) => {
|
|
||||||
if (context.steps.length === 0) {
|
|
||||||
return "intake";
|
|
||||||
}
|
|
||||||
|
|
||||||
const last = context.steps[context.steps.length - 1];
|
|
||||||
|
|
||||||
if (last.role === "intake") {
|
|
||||||
const meta = last.meta as WorkflowMeta["intake"];
|
|
||||||
return meta.valid ? "issue-reader" : END;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (last.role === "issue-reader") {
|
|
||||||
const meta = last.meta as WorkflowMeta["issue-reader"];
|
|
||||||
return meta.fetchOk ? "planner" : END;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (last.role === "planner") {
|
|
||||||
const meta = last.meta as WorkflowMeta["planner"];
|
|
||||||
return meta.planningOk ? "implementer" : END;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (last.role === "implementer") {
|
|
||||||
const meta = last.meta as WorkflowMeta["implementer"];
|
|
||||||
if (meta.implementationOk) return "reviewer";
|
|
||||||
return canRetryImplementer(context.steps) ? "implementer" : END;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (last.role === "reviewer") {
|
|
||||||
if (last.meta.approved) return "tester";
|
|
||||||
return canRetryImplementer(context.steps) ? "implementer" : END;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (last.role === "tester") {
|
|
||||||
const meta = last.meta as WorkflowMeta["tester"];
|
|
||||||
if (meta.passed) return "pr-publisher";
|
|
||||||
return canRetryImplementer(context.steps) ? "implementer" : END;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (last.role === "pr-publisher") {
|
|
||||||
return END;
|
|
||||||
}
|
|
||||||
|
|
||||||
return END;
|
|
||||||
};
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "gitea-issue-solver-workflow",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build": "esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/nerve-core": "latest",
|
|
||||||
"@uncaged/nerve-workflow-utils": "latest",
|
|
||||||
"zod": "^4.3.6"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"esbuild": "^0.27.0",
|
|
||||||
"typescript": "^5.7.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
|
|
||||||
import { cursorAgent, spawnSafe } from "@uncaged/nerve-workflow-utils";
|
|
||||||
import { lastMetaForRole } from "../../lib/meta-helpers.js";
|
|
||||||
import { formatSpawnFailure } from "../../lib/spawn-utils.js";
|
|
||||||
import { slugify } from "../../lib/text-utils.js";
|
|
||||||
import type { IntakeMeta } from "../intake/index.js";
|
|
||||||
import type { IssueReaderMeta } from "../issue-reader/index.js";
|
|
||||||
import type { PlannerMeta } from "../planner/index.js";
|
|
||||||
import { buildImplementerPrompt } from "./prompt.js";
|
|
||||||
|
|
||||||
export type ImplementerMeta = {
|
|
||||||
branchName: string | null;
|
|
||||||
changedFiles: string[] | null;
|
|
||||||
implementationOk: boolean;
|
|
||||||
attempt: number;
|
|
||||||
failureKind: "none" | "branch_failed" | "agent_failed" | "no_diff";
|
|
||||||
failureReason: string | null;
|
|
||||||
implementationLog: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BuildImplementerDeps = {
|
|
||||||
nerveRoot: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildImplementerRole({ nerveRoot }: BuildImplementerDeps): Role<ImplementerMeta> {
|
|
||||||
return async (_start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<ImplementerMeta>> => {
|
|
||||||
const plannerMeta = lastMetaForRole<PlannerMeta>(messages, "planner");
|
|
||||||
const intakeMeta = lastMetaForRole<IntakeMeta>(messages, "intake");
|
|
||||||
const issueMeta = lastMetaForRole<IssueReaderMeta>(messages, "issue-reader");
|
|
||||||
const attempt = messages.filter((message) => message.role === "implementer").length + 1;
|
|
||||||
|
|
||||||
if (
|
|
||||||
plannerMeta === null ||
|
|
||||||
!plannerMeta.planningOk ||
|
|
||||||
plannerMeta.plan === null ||
|
|
||||||
intakeMeta === null ||
|
|
||||||
intakeMeta.issueNumber === null
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
content: "implementer cannot continue: missing planner or intake context",
|
|
||||||
meta: {
|
|
||||||
branchName: null,
|
|
||||||
changedFiles: null,
|
|
||||||
implementationOk: false,
|
|
||||||
attempt,
|
|
||||||
failureKind: "agent_failed",
|
|
||||||
failureReason: "missing planner/intake context",
|
|
||||||
implementationLog: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const slugSource = issueMeta?.issue?.title ?? plannerMeta.plan.split("\n")[0] ?? "fix";
|
|
||||||
const branchName = `fix/issue-${intakeMeta.issueNumber}-${slugify(slugSource)}`;
|
|
||||||
|
|
||||||
const existsResult = await spawnSafe("git", ["rev-parse", "--verify", `refs/heads/${branchName}`], {
|
|
||||||
cwd: nerveRoot,
|
|
||||||
env: null,
|
|
||||||
timeoutMs: 10_000,
|
|
||||||
});
|
|
||||||
const checkoutResult = existsResult.ok
|
|
||||||
? await spawnSafe("git", ["checkout", branchName], { cwd: nerveRoot, env: null, timeoutMs: 20_000 })
|
|
||||||
: await spawnSafe("git", ["checkout", "-b", branchName], { cwd: nerveRoot, env: null, timeoutMs: 20_000 });
|
|
||||||
|
|
||||||
if (!checkoutResult.ok) {
|
|
||||||
return {
|
|
||||||
content: `branch setup failed: ${formatSpawnFailure(checkoutResult.error)}`,
|
|
||||||
meta: {
|
|
||||||
branchName,
|
|
||||||
changedFiles: null,
|
|
||||||
implementationOk: false,
|
|
||||||
attempt,
|
|
||||||
failureKind: "branch_failed",
|
|
||||||
failureReason: formatSpawnFailure(checkoutResult.error),
|
|
||||||
implementationLog: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const prompt = buildImplementerPrompt({
|
|
||||||
issueNumber: intakeMeta.issueNumber,
|
|
||||||
plan: plannerMeta.plan,
|
|
||||||
targetFilesText: plannerMeta.targetFiles?.join("\n") ?? "(not specified)",
|
|
||||||
testCommandsText: plannerMeta.testCommands?.join("\n") ?? "(not specified)",
|
|
||||||
});
|
|
||||||
|
|
||||||
const agentResult = await cursorAgent({
|
|
||||||
prompt,
|
|
||||||
mode: "default",
|
|
||||||
model: "auto",
|
|
||||||
cwd: nerveRoot,
|
|
||||||
env: null,
|
|
||||||
timeoutMs: null,
|
|
||||||
});
|
|
||||||
if (!agentResult.ok) {
|
|
||||||
return {
|
|
||||||
content: `implementer agent failed: ${formatSpawnFailure(agentResult.error)}`,
|
|
||||||
meta: {
|
|
||||||
branchName,
|
|
||||||
changedFiles: null,
|
|
||||||
implementationOk: false,
|
|
||||||
attempt,
|
|
||||||
failureKind: "agent_failed",
|
|
||||||
failureReason: formatSpawnFailure(agentResult.error),
|
|
||||||
implementationLog: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const diffResult = await spawnSafe("git", ["diff", "--name-only"], {
|
|
||||||
cwd: nerveRoot,
|
|
||||||
env: null,
|
|
||||||
timeoutMs: 15_000,
|
|
||||||
});
|
|
||||||
if (!diffResult.ok) {
|
|
||||||
return {
|
|
||||||
content: `implementation finished but diff check failed: ${formatSpawnFailure(diffResult.error)}`,
|
|
||||||
meta: {
|
|
||||||
branchName,
|
|
||||||
changedFiles: null,
|
|
||||||
implementationOk: false,
|
|
||||||
attempt,
|
|
||||||
failureKind: "no_diff",
|
|
||||||
failureReason: formatSpawnFailure(diffResult.error),
|
|
||||||
implementationLog: agentResult.value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const changedFiles = diffResult.value.stdout
|
|
||||||
.split("\n")
|
|
||||||
.map((file) => file.trim())
|
|
||||||
.filter((file) => file.length > 0);
|
|
||||||
if (changedFiles.length === 0) {
|
|
||||||
return {
|
|
||||||
content: "implementer made no local diff",
|
|
||||||
meta: {
|
|
||||||
branchName,
|
|
||||||
changedFiles: [],
|
|
||||||
implementationOk: false,
|
|
||||||
attempt,
|
|
||||||
failureKind: "no_diff",
|
|
||||||
failureReason: "git diff --name-only is empty",
|
|
||||||
implementationLog: agentResult.value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: `branch ready: ${branchName}\nchanged files:\n${changedFiles.join("\n")}`,
|
|
||||||
meta: {
|
|
||||||
branchName,
|
|
||||||
changedFiles,
|
|
||||||
implementationOk: true,
|
|
||||||
attempt,
|
|
||||||
failureKind: "none",
|
|
||||||
failureReason: null,
|
|
||||||
implementationLog: agentResult.value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
export function buildImplementerPrompt(params: {
|
|
||||||
issueNumber: number;
|
|
||||||
plan: string;
|
|
||||||
targetFilesText: string;
|
|
||||||
testCommandsText: string;
|
|
||||||
}): string {
|
|
||||||
const { issueNumber, plan, targetFilesText, testCommandsText } = params;
|
|
||||||
return `Implement the planned fix in this repository.
|
|
||||||
|
|
||||||
Issue #${issueNumber}
|
|
||||||
Plan:
|
|
||||||
${plan}
|
|
||||||
|
|
||||||
Target files:
|
|
||||||
${targetFilesText}
|
|
||||||
|
|
||||||
Test commands:
|
|
||||||
${testCommandsText}
|
|
||||||
|
|
||||||
Apply the code changes now with minimal, focused edits.`;
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import type { Role, RoleResult, StartStep } from "@uncaged/nerve-core";
|
|
||||||
|
|
||||||
export type IntakeMeta = {
|
|
||||||
issueUrl: string;
|
|
||||||
host: string | null;
|
|
||||||
owner: string | null;
|
|
||||||
repo: string | null;
|
|
||||||
issueNumber: number | null;
|
|
||||||
valid: boolean;
|
|
||||||
failureKind: "none" | "invalid_input";
|
|
||||||
failureReason: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseIssueUrl(raw: string): IntakeMeta {
|
|
||||||
const issueUrl = raw.trim();
|
|
||||||
const match = issueUrl.match(/^https?:\/\/([^/\s]+)\/([^/\s]+)\/([^/\s]+)\/issues\/(\d+)(?:[/?#].*)?$/i);
|
|
||||||
if (match === null) {
|
|
||||||
return {
|
|
||||||
issueUrl,
|
|
||||||
host: null,
|
|
||||||
owner: null,
|
|
||||||
repo: null,
|
|
||||||
issueNumber: null,
|
|
||||||
valid: false,
|
|
||||||
failureKind: "invalid_input",
|
|
||||||
failureReason: `invalid issue URL: ${issueUrl}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const issueNumber = Number(match[4]);
|
|
||||||
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
|
|
||||||
return {
|
|
||||||
issueUrl,
|
|
||||||
host: null,
|
|
||||||
owner: null,
|
|
||||||
repo: null,
|
|
||||||
issueNumber: null,
|
|
||||||
valid: false,
|
|
||||||
failureKind: "invalid_input",
|
|
||||||
failureReason: `invalid issue number: ${match[4]}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
issueUrl,
|
|
||||||
host: match[1],
|
|
||||||
owner: match[2],
|
|
||||||
repo: match[3],
|
|
||||||
issueNumber,
|
|
||||||
valid: true,
|
|
||||||
failureKind: "none",
|
|
||||||
failureReason: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildIntakeRole(): Role<IntakeMeta> {
|
|
||||||
return async (start: StartStep): Promise<RoleResult<IntakeMeta>> => {
|
|
||||||
const parsed = parseIssueUrl(start.content);
|
|
||||||
if (!parsed.valid) {
|
|
||||||
return { content: parsed.failureReason ?? "invalid issue URL", meta: parsed };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
content: `parsed issue URL: ${parsed.owner}/${parsed.repo}#${parsed.issueNumber} @ ${parsed.host}`,
|
|
||||||
meta: parsed,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
/** Intake role parses the issue URL from the workflow start message; no LLM prompt. */
|
|
||||||
export function intakePrompt(): string {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
@ -1,259 +0,0 @@
|
|||||||
import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
|
|
||||||
import { llmExtract, spawnSafe } from "@uncaged/nerve-workflow-utils";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ISSUE_READER_MAX_ATTEMPTS } from "../../lib/constants.js";
|
|
||||||
import { lastMetaForRole } from "../../lib/meta-helpers.js";
|
|
||||||
import type { Provider } from "../../lib/provider.js";
|
|
||||||
import { resolveDashScopeProvider as resolveProvider } from "../../lib/provider.js";
|
|
||||||
import { runWithRetries } from "../../lib/run-with-retries.js";
|
|
||||||
import { classifyIssueReaderFailure, formatSpawnFailure } from "../../lib/spawn-utils.js";
|
|
||||||
import type { IntakeMeta } from "../intake/index.js";
|
|
||||||
|
|
||||||
export type IssueData = {
|
|
||||||
id: number;
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
state: string;
|
|
||||||
labels: string[];
|
|
||||||
comments: Array<{
|
|
||||||
author: string;
|
|
||||||
body: string;
|
|
||||||
createdAt: string;
|
|
||||||
}>;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IssueReaderMeta = {
|
|
||||||
issue: IssueData | null;
|
|
||||||
fetchOk: boolean;
|
|
||||||
transientError: boolean;
|
|
||||||
attempt: number;
|
|
||||||
failureKind: "none" | "transient" | "permanent";
|
|
||||||
failureReason: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const issueSchema = z.object({
|
|
||||||
id: z.number().int().default(0),
|
|
||||||
number: z.number().int().default(0),
|
|
||||||
title: z.string().default(""),
|
|
||||||
body: z.string().default(""),
|
|
||||||
state: z.string().default("unknown"),
|
|
||||||
labels: z.array(z.string().default("")).default([]),
|
|
||||||
comments: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
author: z.string().default("unknown"),
|
|
||||||
body: z.string().default(""),
|
|
||||||
createdAt: z.string().default(""),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.default([]),
|
|
||||||
url: z.string().default(""),
|
|
||||||
});
|
|
||||||
|
|
||||||
function toIssueFromJson(raw: unknown): IssueData | null {
|
|
||||||
if (typeof raw !== "object" || raw === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const obj = raw as Record<string, unknown>;
|
|
||||||
const labels = (Array.isArray(obj.labels) ? obj.labels : [])
|
|
||||||
.map((label) => {
|
|
||||||
if (typeof label === "string") {
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
if (typeof label === "object" && label !== null && typeof (label as Record<string, unknown>).name === "string") {
|
|
||||||
return String((label as Record<string, unknown>).name);
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
})
|
|
||||||
.filter((label) => label.length > 0);
|
|
||||||
const comments = (Array.isArray(obj.comments) ? obj.comments : []).map((comment) => {
|
|
||||||
const item = (comment ?? {}) as Record<string, unknown>;
|
|
||||||
const userObj = (item.user ?? {}) as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
author:
|
|
||||||
typeof item.author === "string"
|
|
||||||
? item.author
|
|
||||||
: typeof userObj.login === "string"
|
|
||||||
? userObj.login
|
|
||||||
: "unknown",
|
|
||||||
body: typeof item.body === "string" ? item.body : "",
|
|
||||||
createdAt:
|
|
||||||
typeof item.created_at === "string"
|
|
||||||
? item.created_at
|
|
||||||
: typeof item.createdAt === "string"
|
|
||||||
? item.createdAt
|
|
||||||
: "",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = issueSchema.parse({
|
|
||||||
id: Number(obj.id ?? 0),
|
|
||||||
number: Number(obj.number ?? 0),
|
|
||||||
title: typeof obj.title === "string" ? obj.title : "",
|
|
||||||
body: typeof obj.body === "string" ? obj.body : "",
|
|
||||||
state: typeof obj.state === "string" ? obj.state : "unknown",
|
|
||||||
labels,
|
|
||||||
comments,
|
|
||||||
url: typeof obj.url === "string" ? obj.url : "",
|
|
||||||
});
|
|
||||||
return parsed.number > 0 ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseIssueFromText(text: string, provider: Provider | null): Promise<IssueData | null> {
|
|
||||||
if (provider === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const extracted = await llmExtract({ text, schema: issueSchema, provider });
|
|
||||||
if (!extracted.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return extracted.value.number > 0 ? extracted.value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BuildIssueReaderDeps = {
|
|
||||||
nerveRoot: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildIssueReaderRole({ nerveRoot }: BuildIssueReaderDeps): Role<IssueReaderMeta> {
|
|
||||||
return async (_start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<IssueReaderMeta>> => {
|
|
||||||
const intakeMeta = lastMetaForRole<IntakeMeta>(messages, "intake");
|
|
||||||
const attempt = messages.filter((message) => message.role === "issue-reader").length + 1;
|
|
||||||
|
|
||||||
if (
|
|
||||||
intakeMeta === null ||
|
|
||||||
!intakeMeta.valid ||
|
|
||||||
intakeMeta.owner === null ||
|
|
||||||
intakeMeta.repo === null ||
|
|
||||||
intakeMeta.issueNumber === null
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
content: "issue-reader cannot continue: missing valid intake meta",
|
|
||||||
meta: {
|
|
||||||
issue: null,
|
|
||||||
fetchOk: false,
|
|
||||||
transientError: false,
|
|
||||||
attempt,
|
|
||||||
failureKind: "permanent",
|
|
||||||
failureReason: "missing valid intake meta",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const repoSpec = `${intakeMeta.owner}/${intakeMeta.repo}`;
|
|
||||||
const jsonRun = await runWithRetries(async () => {
|
|
||||||
const run = await spawnSafe(
|
|
||||||
"tea",
|
|
||||||
[
|
|
||||||
"issue",
|
|
||||||
"show",
|
|
||||||
String(intakeMeta.issueNumber),
|
|
||||||
"--repo",
|
|
||||||
repoSpec,
|
|
||||||
"--comments",
|
|
||||||
"--json",
|
|
||||||
"id,number,title,body,state,labels,comments,url",
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: nerveRoot,
|
|
||||||
env: null,
|
|
||||||
timeoutMs: 60_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (run.ok) {
|
|
||||||
return { ok: true, stdout: run.value.stdout, stderr: run.value.stderr };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: run.error,
|
|
||||||
lastStdout: run.error.kind === "spawn_failed" ? "" : run.error.stdout,
|
|
||||||
lastStderr: run.error.kind === "spawn_failed" ? run.error.message : run.error.stderr,
|
|
||||||
};
|
|
||||||
}, ISSUE_READER_MAX_ATTEMPTS);
|
|
||||||
|
|
||||||
if (jsonRun.ok) {
|
|
||||||
try {
|
|
||||||
const issue = toIssueFromJson(JSON.parse(jsonRun.stdout) as unknown);
|
|
||||||
if (issue !== null) {
|
|
||||||
return {
|
|
||||||
content: `issue fetched: #${issue.number} ${issue.title}`,
|
|
||||||
meta: {
|
|
||||||
issue,
|
|
||||||
fetchOk: true,
|
|
||||||
transientError: false,
|
|
||||||
attempt,
|
|
||||||
failureKind: "none",
|
|
||||||
failureReason: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fallback to text parsing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const textRun = await runWithRetries(async () => {
|
|
||||||
const run = await spawnSafe(
|
|
||||||
"tea",
|
|
||||||
["issue", "show", String(intakeMeta.issueNumber), "--repo", repoSpec, "--comments"],
|
|
||||||
{
|
|
||||||
cwd: nerveRoot,
|
|
||||||
env: null,
|
|
||||||
timeoutMs: 60_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (run.ok) {
|
|
||||||
return { ok: true, stdout: run.value.stdout, stderr: run.value.stderr };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: run.error,
|
|
||||||
lastStdout: run.error.kind === "spawn_failed" ? "" : run.error.stdout,
|
|
||||||
lastStderr: run.error.kind === "spawn_failed" ? run.error.message : run.error.stderr,
|
|
||||||
};
|
|
||||||
}, ISSUE_READER_MAX_ATTEMPTS);
|
|
||||||
|
|
||||||
if (textRun.ok) {
|
|
||||||
const provider = await resolveProvider(nerveRoot);
|
|
||||||
const issue = await parseIssueFromText(textRun.stdout, provider);
|
|
||||||
if (issue !== null) {
|
|
||||||
return {
|
|
||||||
content: `issue fetched (text+extract): #${issue.number} ${issue.title}`,
|
|
||||||
meta: {
|
|
||||||
issue,
|
|
||||||
fetchOk: true,
|
|
||||||
transientError: false,
|
|
||||||
attempt,
|
|
||||||
failureKind: "none",
|
|
||||||
failureReason: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
content: "tea issue output received, but could not parse structured fields.",
|
|
||||||
meta: {
|
|
||||||
issue: null,
|
|
||||||
fetchOk: false,
|
|
||||||
transientError: false,
|
|
||||||
attempt,
|
|
||||||
failureKind: "permanent",
|
|
||||||
failureReason: "unable to parse tea issue output",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const classified = classifyIssueReaderFailure(textRun.error);
|
|
||||||
return {
|
|
||||||
content: `issue read failed after retries: ${formatSpawnFailure(textRun.error)}`,
|
|
||||||
meta: {
|
|
||||||
issue: null,
|
|
||||||
fetchOk: false,
|
|
||||||
transientError: classified.transientError,
|
|
||||||
attempt,
|
|
||||||
failureKind: classified.failureKind,
|
|
||||||
failureReason: formatSpawnFailure(textRun.error),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
/** Issue reader fetches issue data via tea CLI; no separate LLM prompt. */
|
|
||||||
export function issueReaderPrompt(): string {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
|
|
||||||
import { cursorAgent, llmExtract } from "@uncaged/nerve-workflow-utils";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { readNerveConfigText } from "../../lib/nerve-read.js";
|
|
||||||
import { resolveDashScopeProvider } from "../../lib/provider.js";
|
|
||||||
import { lastMetaForRole } from "../../lib/meta-helpers.js";
|
|
||||||
import { formatSpawnFailure } from "../../lib/spawn-utils.js";
|
|
||||||
import type { IssueReaderMeta } from "../issue-reader/index.js";
|
|
||||||
import { buildPlannerPrompt } from "./prompt.js";
|
|
||||||
|
|
||||||
export type PlannerMeta = {
|
|
||||||
plan: string | null;
|
|
||||||
targetFiles: string[] | null;
|
|
||||||
testCommands: string[] | null;
|
|
||||||
riskNotes: string[] | null;
|
|
||||||
planningOk: boolean;
|
|
||||||
failureKind: "none" | "planning_failed";
|
|
||||||
failureReason: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const planSchema = z.object({
|
|
||||||
targetFiles: z.array(z.string().default("")).default([]),
|
|
||||||
testCommands: z.array(z.string().default("")).default([]),
|
|
||||||
riskNotes: z.array(z.string().default("")).default([]),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type BuildPlannerDeps = {
|
|
||||||
nerveRoot: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildPlannerRole({ nerveRoot }: BuildPlannerDeps): Role<PlannerMeta> {
|
|
||||||
return async (_start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<PlannerMeta>> => {
|
|
||||||
const issueMeta = lastMetaForRole<IssueReaderMeta>(messages, "issue-reader");
|
|
||||||
if (issueMeta === null || !issueMeta.fetchOk || issueMeta.issue === null) {
|
|
||||||
return {
|
|
||||||
content: "planner cannot continue: issue-reader has no issue data",
|
|
||||||
meta: {
|
|
||||||
plan: null,
|
|
||||||
targetFiles: null,
|
|
||||||
testCommands: null,
|
|
||||||
riskNotes: null,
|
|
||||||
planningOk: false,
|
|
||||||
failureKind: "planning_failed",
|
|
||||||
failureReason: "missing issue data",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const prompt = buildPlannerPrompt({
|
|
||||||
issue: issueMeta.issue,
|
|
||||||
nerveYamlText: readNerveConfigText(nerveRoot),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await cursorAgent({
|
|
||||||
prompt,
|
|
||||||
mode: "ask",
|
|
||||||
model: "auto",
|
|
||||||
cwd: nerveRoot,
|
|
||||||
env: null,
|
|
||||||
timeoutMs: null,
|
|
||||||
});
|
|
||||||
if (!result.ok) {
|
|
||||||
return {
|
|
||||||
content: `planner failed: ${formatSpawnFailure(result.error)}`,
|
|
||||||
meta: {
|
|
||||||
plan: null,
|
|
||||||
targetFiles: null,
|
|
||||||
testCommands: null,
|
|
||||||
riskNotes: null,
|
|
||||||
planningOk: false,
|
|
||||||
failureKind: "planning_failed",
|
|
||||||
failureReason: formatSpawnFailure(result.error),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const plan = result.value;
|
|
||||||
const provider = await resolveDashScopeProvider(nerveRoot);
|
|
||||||
if (provider === null) {
|
|
||||||
return {
|
|
||||||
content: plan,
|
|
||||||
meta: {
|
|
||||||
plan,
|
|
||||||
targetFiles: null,
|
|
||||||
testCommands: null,
|
|
||||||
riskNotes: null,
|
|
||||||
planningOk: true,
|
|
||||||
failureKind: "none",
|
|
||||||
failureReason: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const structured = await llmExtract({ text: plan, schema: planSchema, provider });
|
|
||||||
if (!structured.ok) {
|
|
||||||
return {
|
|
||||||
content: `${plan}\n\n[llmExtract error] ${JSON.stringify(structured.error)}`,
|
|
||||||
meta: {
|
|
||||||
plan,
|
|
||||||
targetFiles: null,
|
|
||||||
testCommands: null,
|
|
||||||
riskNotes: null,
|
|
||||||
planningOk: true,
|
|
||||||
failureKind: "none",
|
|
||||||
failureReason: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: plan,
|
|
||||||
meta: {
|
|
||||||
plan,
|
|
||||||
targetFiles: structured.value.targetFiles,
|
|
||||||
testCommands: structured.value.testCommands,
|
|
||||||
riskNotes: structured.value.riskNotes,
|
|
||||||
planningOk: true,
|
|
||||||
failureKind: "none",
|
|
||||||
failureReason: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import { nerveAgentContext } from "@uncaged/nerve-workflow-utils";
|
|
||||||
import type { IssueData } from "../issue-reader/index.js";
|
|
||||||
|
|
||||||
export function buildPlannerPrompt(params: {
|
|
||||||
issue: IssueData;
|
|
||||||
nerveYamlText: string;
|
|
||||||
}): string {
|
|
||||||
const { issue, nerveYamlText } = params;
|
|
||||||
return `You are planning a fix for a Gitea issue in this repository.
|
|
||||||
|
|
||||||
${nerveAgentContext}
|
|
||||||
|
|
||||||
Issue URL: ${issue.url}
|
|
||||||
Issue title: ${issue.title}
|
|
||||||
Issue body:
|
|
||||||
${issue.body}
|
|
||||||
|
|
||||||
Issue comments:
|
|
||||||
${issue.comments.map((c) => `- ${c.author} (${c.createdAt}): ${c.body}`).join("\n") || "(none)"}
|
|
||||||
|
|
||||||
Current nerve.yaml:
|
|
||||||
\`\`\`yaml
|
|
||||||
${nerveYamlText}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Output implementation-ready markdown with sections:
|
|
||||||
1) Problem understanding
|
|
||||||
2) Change strategy
|
|
||||||
3) Test strategy (commands)
|
|
||||||
4) Risks`;
|
|
||||||
}
|
|
||||||
@ -1,173 +0,0 @@
|
|||||||
import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
|
|
||||||
import { spawnSafe } from "@uncaged/nerve-workflow-utils";
|
|
||||||
import { PUBLISHER_MAX_ATTEMPTS } from "../../lib/constants.js";
|
|
||||||
import { lastMetaForRole } from "../../lib/meta-helpers.js";
|
|
||||||
import { runWithRetries } from "../../lib/run-with-retries.js";
|
|
||||||
import { classifyPublisherFailure, formatSpawnFailure } from "../../lib/spawn-utils.js";
|
|
||||||
import { extractPrInfo } from "../../lib/text-utils.js";
|
|
||||||
import type { ImplementerMeta } from "../implementer/index.js";
|
|
||||||
import type { IntakeMeta } from "../intake/index.js";
|
|
||||||
import type { IssueReaderMeta } from "../issue-reader/index.js";
|
|
||||||
import type { PlannerMeta } from "../planner/index.js";
|
|
||||||
import type { TesterMeta } from "../tester/index.js";
|
|
||||||
|
|
||||||
export type PrPublisherMeta = {
|
|
||||||
prUrl: string | null;
|
|
||||||
prNumber: number | null;
|
|
||||||
linkedIssue: string | null;
|
|
||||||
published: boolean;
|
|
||||||
attempt: number;
|
|
||||||
failureKind: "none" | "auth_or_network" | "permanent";
|
|
||||||
failureReason: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BuildPrPublisherDeps = {
|
|
||||||
nerveRoot: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildPrPublisherRole({ nerveRoot }: BuildPrPublisherDeps): Role<PrPublisherMeta> {
|
|
||||||
return async (_start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<PrPublisherMeta>> => {
|
|
||||||
const attempt = messages.filter((message) => message.role === "pr-publisher").length + 1;
|
|
||||||
const intakeMeta = lastMetaForRole<IntakeMeta>(messages, "intake");
|
|
||||||
const issueMeta = lastMetaForRole<IssueReaderMeta>(messages, "issue-reader");
|
|
||||||
const plannerMeta = lastMetaForRole<PlannerMeta>(messages, "planner");
|
|
||||||
const implementerMeta = lastMetaForRole<ImplementerMeta>(messages, "implementer");
|
|
||||||
const testerMeta = lastMetaForRole<TesterMeta>(messages, "tester");
|
|
||||||
|
|
||||||
if (testerMeta === null || !testerMeta.passed) {
|
|
||||||
return {
|
|
||||||
content: "skip PR publishing: tester.passed is not true",
|
|
||||||
meta: {
|
|
||||||
prUrl: null,
|
|
||||||
prNumber: null,
|
|
||||||
linkedIssue: intakeMeta?.issueUrl ?? null,
|
|
||||||
published: false,
|
|
||||||
attempt,
|
|
||||||
failureKind: "permanent",
|
|
||||||
failureReason: "tester did not pass",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
intakeMeta === null ||
|
|
||||||
intakeMeta.owner === null ||
|
|
||||||
intakeMeta.repo === null ||
|
|
||||||
implementerMeta === null ||
|
|
||||||
implementerMeta.branchName === null
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
content: "pr-publisher cannot continue: missing required context",
|
|
||||||
meta: {
|
|
||||||
prUrl: null,
|
|
||||||
prNumber: null,
|
|
||||||
linkedIssue: intakeMeta?.issueUrl ?? null,
|
|
||||||
published: false,
|
|
||||||
attempt,
|
|
||||||
failureKind: "permanent",
|
|
||||||
failureReason: "missing context",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const pushRun = await runWithRetries(async () => {
|
|
||||||
const run = await spawnSafe("git", ["push", "-u", "origin", implementerMeta.branchName as string], {
|
|
||||||
cwd: nerveRoot,
|
|
||||||
env: null,
|
|
||||||
timeoutMs: 180_000,
|
|
||||||
});
|
|
||||||
if (run.ok) {
|
|
||||||
return { ok: true, stdout: run.value.stdout, stderr: run.value.stderr };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: run.error,
|
|
||||||
lastStdout: run.error.kind === "spawn_failed" ? "" : run.error.stdout,
|
|
||||||
lastStderr: run.error.kind === "spawn_failed" ? run.error.message : run.error.stderr,
|
|
||||||
};
|
|
||||||
}, PUBLISHER_MAX_ATTEMPTS);
|
|
||||||
|
|
||||||
if (!pushRun.ok) {
|
|
||||||
const failureKind = classifyPublisherFailure(pushRun.error);
|
|
||||||
return {
|
|
||||||
content: `failed to push branch before PR: ${formatSpawnFailure(pushRun.error)}`,
|
|
||||||
meta: {
|
|
||||||
prUrl: null,
|
|
||||||
prNumber: null,
|
|
||||||
linkedIssue: intakeMeta.issueUrl,
|
|
||||||
published: false,
|
|
||||||
attempt,
|
|
||||||
failureKind,
|
|
||||||
failureReason: formatSpawnFailure(pushRun.error),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortTitle = (issueMeta?.issue?.title ?? "issue fix").slice(0, 72);
|
|
||||||
const title = `fix: ${shortTitle}`;
|
|
||||||
const body = [
|
|
||||||
`Issue: ${intakeMeta.issueUrl}`,
|
|
||||||
"",
|
|
||||||
"## Summary",
|
|
||||||
...(implementerMeta.changedFiles ?? []).map((file) => `- updated \`${file}\``),
|
|
||||||
"",
|
|
||||||
"## Plan",
|
|
||||||
plannerMeta?.plan ?? "(no planner output)",
|
|
||||||
"",
|
|
||||||
"## Test Results",
|
|
||||||
testerMeta.testCommandResults?.map((r) => `- ${r.ok ? "PASS" : "FAIL"} \`${r.command}\``).join("\n") ??
|
|
||||||
"- no test logs",
|
|
||||||
].join("\n");
|
|
||||||
const repoSpec = `${intakeMeta.owner}/${intakeMeta.repo}`;
|
|
||||||
|
|
||||||
const prRun = await runWithRetries(async () => {
|
|
||||||
const run = await spawnSafe(
|
|
||||||
"tea",
|
|
||||||
["pr", "create", "--repo", repoSpec, "--title", title, "--body", body, "--head", implementerMeta.branchName as string],
|
|
||||||
{
|
|
||||||
cwd: nerveRoot,
|
|
||||||
env: null,
|
|
||||||
timeoutMs: 120_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (run.ok) {
|
|
||||||
return { ok: true, stdout: run.value.stdout, stderr: run.value.stderr };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: run.error,
|
|
||||||
lastStdout: run.error.kind === "spawn_failed" ? "" : run.error.stdout,
|
|
||||||
lastStderr: run.error.kind === "spawn_failed" ? run.error.message : run.error.stderr,
|
|
||||||
};
|
|
||||||
}, PUBLISHER_MAX_ATTEMPTS);
|
|
||||||
|
|
||||||
if (!prRun.ok) {
|
|
||||||
const failureKind = classifyPublisherFailure(prRun.error);
|
|
||||||
return {
|
|
||||||
content: `PR creation failed: ${formatSpawnFailure(prRun.error)}`,
|
|
||||||
meta: {
|
|
||||||
prUrl: null,
|
|
||||||
prNumber: null,
|
|
||||||
linkedIssue: intakeMeta.issueUrl,
|
|
||||||
published: false,
|
|
||||||
attempt,
|
|
||||||
failureKind,
|
|
||||||
failureReason: formatSpawnFailure(prRun.error),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = extractPrInfo(`${prRun.stdout}\n${prRun.stderr}`);
|
|
||||||
return {
|
|
||||||
content: `PR created: ${title}\n${prRun.stdout}`,
|
|
||||||
meta: {
|
|
||||||
prUrl: info.prUrl,
|
|
||||||
prNumber: info.prNumber,
|
|
||||||
linkedIssue: intakeMeta.issueUrl,
|
|
||||||
published: true,
|
|
||||||
attempt,
|
|
||||||
failureKind: "none",
|
|
||||||
failureReason: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
/** PR publisher runs git push and tea pr create; no LLM prompt. */
|
|
||||||
export function prPublisherPrompt(): string {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
|
|
||||||
import { lastMetaForRole } from "../../lib/meta-helpers.js";
|
|
||||||
import { runTestCommand } from "../../lib/run-test-command.js";
|
|
||||||
import type { ImplementerMeta } from "../implementer/index.js";
|
|
||||||
import type { PlannerMeta } from "../planner/index.js";
|
|
||||||
|
|
||||||
export type TestCommandResult = {
|
|
||||||
command: string;
|
|
||||||
ok: boolean;
|
|
||||||
stdoutPreview: string;
|
|
||||||
stderrPreview: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TesterMeta = {
|
|
||||||
passed: boolean;
|
|
||||||
attempt: number;
|
|
||||||
failureReason: string | null;
|
|
||||||
testCommandResults: TestCommandResult[] | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BuildTesterDeps = {
|
|
||||||
nerveRoot: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildTesterRole({ nerveRoot }: BuildTesterDeps): Role<TesterMeta> {
|
|
||||||
return async (_start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<TesterMeta>> => {
|
|
||||||
const plannerMeta = lastMetaForRole<PlannerMeta>(messages, "planner");
|
|
||||||
const implementerMeta = lastMetaForRole<ImplementerMeta>(messages, "implementer");
|
|
||||||
const attempt = messages.filter((message) => message.role === "tester").length + 1;
|
|
||||||
|
|
||||||
if (implementerMeta === null || !implementerMeta.implementationOk || implementerMeta.branchName === null) {
|
|
||||||
return {
|
|
||||||
content: "tester cannot continue: no successful implementer output",
|
|
||||||
meta: {
|
|
||||||
passed: false,
|
|
||||||
attempt,
|
|
||||||
failureReason: "no successful implementer output",
|
|
||||||
testCommandResults: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const commands =
|
|
||||||
plannerMeta?.testCommands !== null && plannerMeta?.testCommands !== undefined && plannerMeta.testCommands.length > 0
|
|
||||||
? plannerMeta.testCommands
|
|
||||||
: ["pnpm test"];
|
|
||||||
|
|
||||||
const testCommandResults: TestCommandResult[] = [];
|
|
||||||
for (const command of commands) {
|
|
||||||
const run = await runTestCommand(nerveRoot, command);
|
|
||||||
testCommandResults.push({
|
|
||||||
command,
|
|
||||||
ok: run.ok,
|
|
||||||
stdoutPreview: run.stdoutPreview,
|
|
||||||
stderrPreview: run.stderrPreview,
|
|
||||||
});
|
|
||||||
if (!run.ok) {
|
|
||||||
return {
|
|
||||||
content: `test failed: ${command}\n${run.reason ?? "unknown error"}`,
|
|
||||||
meta: {
|
|
||||||
passed: false,
|
|
||||||
attempt,
|
|
||||||
failureReason: `command failed: ${command} (${run.reason ?? "unknown error"})`,
|
|
||||||
testCommandResults,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: `all tests passed on branch ${implementerMeta.branchName}`,
|
|
||||||
meta: {
|
|
||||||
passed: true,
|
|
||||||
attempt,
|
|
||||||
failureReason: null,
|
|
||||||
testCommandResults,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
/** Tester runs shell test commands; no LLM prompt. */
|
|
||||||
export function testerPrompt(): string {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
1
workflows/workflow-generator/.gitignore
vendored
1
workflows/workflow-generator/.gitignore
vendored
@ -1 +0,0 @@
|
|||||||
dist/
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
|
|
||||||
import { createHermesRole } from "@uncaged/nerve-workflow-utils";
|
|
||||||
import { reviewerPrompt } from "./prompt.js";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const reviewerMetaSchema = z.object({
|
|
||||||
approved: z.boolean().describe("true if the diff is clean and ready for tester validation"),
|
|
||||||
});
|
|
||||||
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
|
|
||||||
|
|
||||||
export type BuildReviewerDeps = {
|
|
||||||
provider: LlmProvider;
|
|
||||||
nerveRoot: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildReviewerRole({ provider, nerveRoot }: BuildReviewerDeps) {
|
|
||||||
return createHermesRole<ReviewerMeta>({
|
|
||||||
prompt: async (threadId) => reviewerPrompt({ threadId, nerveRoot }),
|
|
||||||
extract: { provider, schema: reviewerMetaSchema },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
export function reviewerPrompt({ threadId, nerveRoot }: { threadId: string; nerveRoot: string }): string {
|
|
||||||
return `You are a **code reviewer** for Nerve workflow changes. You run after the coder and before the tester.
|
|
||||||
|
|
||||||
**IMPORTANT: The Nerve workspace is at \`${nerveRoot}\`. Always \`cd ${nerveRoot}\` first.**
|
|
||||||
|
|
||||||
Read the workflow thread for context: \`nerve thread ${threadId}\`
|
|
||||||
Read project conventions: \`cat ${nerveRoot}/CONVENTIONS.md\`
|
|
||||||
|
|
||||||
## Your job — static analysis of the git diff
|
|
||||||
|
|
||||||
Run these commands and analyze the output:
|
|
||||||
|
|
||||||
1. **\`cd ${nerveRoot} && git diff --stat\`** — see what files changed
|
|
||||||
2. **\`cd ${nerveRoot} && git diff\`** — read the actual diff
|
|
||||||
3. **\`cd ${nerveRoot} && git status --short\`** — check for untracked files
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
|
|
||||||
Review the diff against CONVENTIONS.md. Key things to catch:
|
|
||||||
|
|
||||||
### 🔴 Reject (approved: false) — tell coder exactly what to fix
|
|
||||||
- **Garbage files**: anything listed under "What NOT to commit" in CONVENTIONS.md
|
|
||||||
- **Secrets/credentials**: API keys, tokens, passwords hardcoded in the diff
|
|
||||||
- **Unrelated changes**: files modified outside the scope of the task
|
|
||||||
- **Convention violations**: patterns that contradict CONVENTIONS.md (e.g. \`interface\` instead of \`type\`, \`class\`, dynamic \`import()\`, optional properties with \`?:\`)
|
|
||||||
|
|
||||||
### ✅ Approve (approved: true) — no comment needed
|
|
||||||
- Diff is clean, focused, follows conventions
|
|
||||||
|
|
||||||
End with:
|
|
||||||
\`\`\`json
|
|
||||||
{ "approved": true }
|
|
||||||
\`\`\`
|
|
||||||
or
|
|
||||||
\`\`\`json
|
|
||||||
{ "approved": false }
|
|
||||||
\`\`\``;
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"lib": ["ES2022"],
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"types": ["node"]
|
|
||||||
},
|
|
||||||
"include": ["./**/*.ts"]
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user