rebase generation
This commit is contained in:
15
tools/lib/project-root.mjs
Normal file
15
tools/lib/project-root.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Resolve the KIS-TOiR project root directory.
|
||||
*
|
||||
* Works from any module file in the tools/ directory by walking up
|
||||
* relative to __dirname.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export function getProjectRoot() {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// From tools/lib, go up two levels: ../.. = project root
|
||||
return path.resolve(path.join(__dirname, "..", ".."));
|
||||
}
|
||||
185
tools/mcp/lib/mcp-server.mjs
Normal file
185
tools/mcp/lib/mcp-server.mjs
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Minimal Model Context Protocol (MCP) server over stdio.
|
||||
*
|
||||
* Implements just enough of the MCP JSON-RPC 2.0 surface to expose
|
||||
* a static list of read-only validation tools to a Claude Code client:
|
||||
* - initialize
|
||||
* - tools/list
|
||||
* - tools/call
|
||||
*
|
||||
* We intentionally avoid the @modelcontextprotocol/sdk dependency here —
|
||||
* the KIS-TOiR optimization policy forbids adding new npm packages for
|
||||
* these lightweight validators, so we speak raw JSON-RPC on stdin/stdout
|
||||
* with Node built-ins only.
|
||||
*
|
||||
* Tool contract:
|
||||
* {
|
||||
* name: string,
|
||||
* description: string,
|
||||
* inputSchema?: JSONSchema,
|
||||
* execute: (args: object) => Promise<{ success: boolean, result?, error? }>
|
||||
* }
|
||||
*
|
||||
* execute() must be read-only, must resolve quickly (<500ms target), and
|
||||
* must NEVER throw — wrap failures in { success: false, error }.
|
||||
*/
|
||||
|
||||
import readline from "node:readline";
|
||||
|
||||
const PROTOCOL_VERSION = "2024-11-05";
|
||||
|
||||
// JSON-RPC 2.0 constants
|
||||
const JSONRPC_VERSION = "2.0";
|
||||
const FIELD_JSONRPC = "jsonrpc";
|
||||
const FIELD_ID = "id";
|
||||
const FIELD_METHOD = "method";
|
||||
const FIELD_PARAMS = "params";
|
||||
const FIELD_RESULT = "result";
|
||||
const FIELD_ERROR = "error";
|
||||
const FIELD_CODE = "code";
|
||||
const FIELD_MESSAGE = "message";
|
||||
const FIELD_DATA = "data";
|
||||
const CONTENT_TYPE_TEXT = "text";
|
||||
const FIELD_CONTENT = "content";
|
||||
const FIELD_TYPE = "type";
|
||||
const FIELD_IS_ERROR = "isError";
|
||||
|
||||
function write(obj) {
|
||||
// MCP framing over stdio is line-delimited JSON (one JSON object per line).
|
||||
process.stdout.write(JSON.stringify(obj) + "\n");
|
||||
}
|
||||
|
||||
function ok(id, result) {
|
||||
write({ [FIELD_JSONRPC]: JSONRPC_VERSION, [FIELD_ID]: id, [FIELD_RESULT]: result });
|
||||
}
|
||||
|
||||
function fail(id, code, message, data) {
|
||||
write({
|
||||
[FIELD_JSONRPC]: JSONRPC_VERSION,
|
||||
[FIELD_ID]: id,
|
||||
[FIELD_ERROR]: { [FIELD_CODE]: code, [FIELD_MESSAGE]: message, [FIELD_DATA]: data },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an MCP server. Blocks on stdin until the parent process closes it.
|
||||
*/
|
||||
export function runMCPServer({ name, version = "0.1.0", tools }) {
|
||||
const byName = new Map(tools.map((t) => [t.name, t]));
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
// Log to stderr so we don't corrupt the stdout JSON-RPC channel.
|
||||
const log = (level, msg) =>
|
||||
process.stderr.write(`[${level}] ${name}: ${msg}\n`);
|
||||
|
||||
log("INFO", `starting (tools=${tools.length})`);
|
||||
|
||||
rl.on("line", async (line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
let req;
|
||||
try {
|
||||
req = JSON.parse(trimmed);
|
||||
} catch (e) {
|
||||
log("WARN", `invalid JSON on stdin: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, method, params } = req;
|
||||
|
||||
// Notifications have no id and expect no response.
|
||||
const isNotification = id === undefined || id === null;
|
||||
|
||||
try {
|
||||
if (method === "initialize") {
|
||||
ok(id, {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name, version },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "tools/list") {
|
||||
ok(id, {
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema ?? {
|
||||
[FIELD_TYPE]: "object",
|
||||
properties: {},
|
||||
additionalProperties: true,
|
||||
},
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "tools/call") {
|
||||
const toolName = params?.name;
|
||||
const tool = byName.get(toolName);
|
||||
if (!tool) {
|
||||
fail(id, -32601, `Unknown tool: ${toolName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
// Enforce per-tool timeout: tools MUST resolve within 2000ms.
|
||||
// Prevents hung tools from blocking the orchestrator.
|
||||
const toolTimeout = new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("Tool execution timeout (>2000ms)")),
|
||||
2000
|
||||
)
|
||||
);
|
||||
result = await Promise.race([
|
||||
tool.execute(params?.arguments ?? {}),
|
||||
toolTimeout,
|
||||
]);
|
||||
} catch (e) {
|
||||
// Capture all errors (timeout, execution, etc.) uniformly.
|
||||
result = { success: false, error: e?.message ?? String(e) };
|
||||
}
|
||||
|
||||
ok(id, {
|
||||
[FIELD_CONTENT]: [
|
||||
{
|
||||
[FIELD_TYPE]: CONTENT_TYPE_TEXT,
|
||||
text:
|
||||
typeof result === "string"
|
||||
? result
|
||||
: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
[FIELD_IS_ERROR]: result?.success === false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method?.startsWith("notifications/")) {
|
||||
// Client lifecycle notifications (initialized, cancelled, ...).
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isNotification) {
|
||||
fail(id, -32601, `Method not found: ${method}`);
|
||||
}
|
||||
} catch (e) {
|
||||
log("ERROR", `handler crashed: ${e?.stack ?? e}`);
|
||||
if (!isNotification) {
|
||||
fail(id, -32603, "Internal server error", { message: e?.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rl.on("close", () => {
|
||||
log("INFO", "stdin closed, exiting");
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
158
tools/mcp/nest-validator.mjs
Normal file
158
tools/mcp/nest-validator.mjs
Normal file
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* MCP server: NestJS module/controller validator (read-only).
|
||||
*
|
||||
* Tools:
|
||||
* - validate_module_source : assert a TypeScript source string is a
|
||||
* well-formed Nest module
|
||||
* - check_app_registration : verify that server/src/app.module.ts
|
||||
* imports and registers a given module class
|
||||
*
|
||||
* Purely lexical — we do not use the TypeScript compiler API (that would
|
||||
* add a dependency). Regex-level checks are sufficient for the structural
|
||||
* invariants the orchestrator cares about.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { runMCPServer } from "./lib/mcp-server.mjs";
|
||||
import { getProjectRoot } from "../lib/project-root.mjs";
|
||||
|
||||
const projectRoot = getProjectRoot();
|
||||
|
||||
// Regex constants for NestJS module validation
|
||||
const DECORATOR_KEYS_RE = /(?:^|[,{\s])(imports|controllers|providers|exports)\s*:/g;
|
||||
const IMPORTS_BLOCK_RE = /imports\s*:\s*\[([\s\S]*?)\]/;
|
||||
|
||||
const tools = [
|
||||
{
|
||||
name: "validate_module_source",
|
||||
description:
|
||||
"Validate that a TypeScript source string declares a well-formed NestJS module: imports @Module from @nestjs/common, applies the @Module decorator with an object argument, and exports a class. Returns the class name and the decorator keys it declares.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: { type: "string", description: "Contents of a *.module.ts file" },
|
||||
},
|
||||
required: ["source"],
|
||||
},
|
||||
execute: async ({ source }) => {
|
||||
if (typeof source !== "string" || source.length === 0) {
|
||||
return { success: false, error: "source must be a non-empty string" };
|
||||
}
|
||||
|
||||
const issues = [];
|
||||
|
||||
if (!/from ['"]@nestjs\/common['"]/.test(source)) {
|
||||
issues.push("does not import from @nestjs/common");
|
||||
}
|
||||
if (!/\bModule\b/.test(source)) {
|
||||
issues.push("does not reference the Module symbol");
|
||||
}
|
||||
|
||||
const decoratorMatch = source.match(/@Module\s*\(\s*\{([\s\S]*?)\}\s*\)/);
|
||||
if (!decoratorMatch) {
|
||||
issues.push("no @Module({...}) decorator found");
|
||||
}
|
||||
|
||||
const classMatch = source.match(/export\s+class\s+(\w+)/);
|
||||
if (!classMatch) {
|
||||
issues.push("no `export class <Name>` declaration");
|
||||
}
|
||||
|
||||
// Extract the top-level keys inside the decorator object so callers
|
||||
// can sanity-check providers/controllers/imports presence.
|
||||
const declaredKeys = [];
|
||||
if (decoratorMatch) {
|
||||
const body = decoratorMatch[1];
|
||||
let m;
|
||||
while ((m = DECORATOR_KEYS_RE.exec(body)) !== null) {
|
||||
declaredKeys.push(m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: issues.length === 0,
|
||||
result: {
|
||||
className: classMatch?.[1] ?? null,
|
||||
declaredKeys,
|
||||
issues,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "check_app_registration",
|
||||
description:
|
||||
"Check whether server/src/app.module.ts imports and registers a given Nest module class. Useful after generator_nest_resources produces a module, to verify the orchestrator has wired it into the parent-owned app.module.ts.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
moduleClass: {
|
||||
type: "string",
|
||||
description: "e.g. `ChangeEquipmentStatusModule`",
|
||||
},
|
||||
appModulePath: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional override for the app.module.ts path (relative to project root). Defaults to server/src/app.module.ts.",
|
||||
},
|
||||
},
|
||||
required: ["moduleClass"],
|
||||
},
|
||||
execute: async ({ moduleClass, appModulePath }) => {
|
||||
if (typeof moduleClass !== "string" || !/^[A-Z]\w*Module$/.test(moduleClass)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "moduleClass must be a PascalCase class name ending in Module",
|
||||
};
|
||||
}
|
||||
|
||||
const relPath = appModulePath ?? "server/src/app.module.ts";
|
||||
const absPath = path.isAbsolute(relPath)
|
||||
? relPath
|
||||
: path.join(projectRoot, relPath);
|
||||
|
||||
let source: string;
|
||||
try {
|
||||
source = fs.readFileSync(absPath, "utf8");
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: `failed to read ${relPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const importRe = new RegExp(
|
||||
`import\\s*\\{[^}]*\\b${moduleClass}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`
|
||||
);
|
||||
const importMatch = source.match(importRe);
|
||||
|
||||
const decoratorMatch = source.match(/@Module\s*\(\s*\{([\s\S]*?)\}\s*\)/);
|
||||
let registered = false;
|
||||
if (decoratorMatch) {
|
||||
const importsBlock = decoratorMatch[1].match(IMPORTS_BLOCK_RE);
|
||||
if (importsBlock) {
|
||||
const importedModules = importsBlock[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
registered = importedModules.includes(moduleClass);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: Boolean(importMatch) && registered,
|
||||
result: {
|
||||
moduleClass,
|
||||
appModulePath: relPath,
|
||||
imported: Boolean(importMatch),
|
||||
importedFrom: importMatch?.[1] ?? null,
|
||||
registered,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
runMCPServer({ name: "kis-toir-nest-validator", version: "0.1.0", tools });
|
||||
141
tools/mcp/npm-validator.mjs
Normal file
141
tools/mcp/npm-validator.mjs
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* MCP server: npm package & semver validator (read-only).
|
||||
*
|
||||
* Tools:
|
||||
* - validate_semver : check that a version string is valid semver
|
||||
* and optionally reject ranges/latest
|
||||
* - check_lockfile_package : look up a package in the root package-lock.json
|
||||
* and return its locked version without touching
|
||||
* the registry
|
||||
*
|
||||
* No network access, no CLI invocations. Pure parsing over local files.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { runMCPServer } from "./lib/mcp-server.mjs";
|
||||
import { getProjectRoot } from "../lib/project-root.mjs";
|
||||
|
||||
const projectRoot = getProjectRoot();
|
||||
|
||||
// SemVer 2.0.0 regex from https://semver.org/ (BSD-licensed).
|
||||
const SEMVER_RE =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
||||
|
||||
function loadLockfile() {
|
||||
const lockPath = path.join(projectRoot, "package-lock.json");
|
||||
if (!fs.existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const tools = [
|
||||
{
|
||||
name: "validate_semver",
|
||||
description:
|
||||
"Validate a version string as strict SemVer 2.0.0. When `rejectRanges` is true, reject caret/tilde/latest and require an exact pinned version (the KIS-TOiR version policy). Returns parsed components on success.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
version: { type: "string", description: "Version string to validate" },
|
||||
rejectRanges: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"If true, treat ^, ~, >, <, *, latest as invalid. Default true.",
|
||||
},
|
||||
},
|
||||
required: ["version"],
|
||||
},
|
||||
execute: async ({ version, rejectRanges = true }) => {
|
||||
if (typeof version !== "string" || !version.trim()) {
|
||||
return { success: false, error: "version must be a non-empty string" };
|
||||
}
|
||||
|
||||
const trimmed = version.trim();
|
||||
|
||||
if (rejectRanges) {
|
||||
if (/^[\^~><=*]/.test(trimmed) || trimmed === "latest") {
|
||||
return {
|
||||
success: false,
|
||||
error: `version '${trimmed}' is a range or tag; policy requires an exact pinned version`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Strip a leading range operator if ranges are allowed.
|
||||
const candidate = rejectRanges ? trimmed : trimmed.replace(/^[\^~><=]+/, "");
|
||||
|
||||
const m = candidate.match(SEMVER_RE);
|
||||
if (!m) {
|
||||
return {
|
||||
success: false,
|
||||
error: `'${trimmed}' is not a valid SemVer 2.0.0 version`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
raw: trimmed,
|
||||
major: Number(m[1]),
|
||||
minor: Number(m[2]),
|
||||
patch: Number(m[3]),
|
||||
prerelease: m[4] ?? null,
|
||||
build: m[5] ?? null,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "check_lockfile_package",
|
||||
description:
|
||||
"Look up a package inside the root package-lock.json and return its locked version. Read-only; does not contact the npm registry. Returns { found: boolean, version?: string, paths: string[] }.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Package name, e.g. @nestjs/core" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
execute: async ({ name }) => {
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
return { success: false, error: "name must be a non-empty string" };
|
||||
}
|
||||
|
||||
const lock = loadLockfile();
|
||||
if (!lock) {
|
||||
return {
|
||||
success: false,
|
||||
error: "package-lock.json not found or unreadable at project root",
|
||||
};
|
||||
}
|
||||
|
||||
const matches = [];
|
||||
const packages = lock.packages ?? {};
|
||||
for (const [key, value] of Object.entries(packages)) {
|
||||
// npm v7+ lockfile: keys look like "node_modules/<pkg>" or
|
||||
// "node_modules/<scope>/<pkg>". Strip the prefix and compare.
|
||||
if (!key) continue;
|
||||
const stripped = key.replace(/^.*node_modules\//, "");
|
||||
if (stripped === name) {
|
||||
matches.push({ path: key, version: value.version });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
name,
|
||||
found: matches.length > 0,
|
||||
occurrences: matches,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
runMCPServer({ name: "kis-toir-npm-validator", version: "0.1.0", tools });
|
||||
178
tools/mcp/prisma-validator.mjs
Normal file
178
tools/mcp/prisma-validator.mjs
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* MCP server: Prisma schema validator (read-only).
|
||||
*
|
||||
* Tools:
|
||||
* - validate_schema : structural checks against a Prisma schema string
|
||||
* - extract_models : return model/enum names and field counts
|
||||
*
|
||||
* These are deliberately NOT a shell-out to `prisma validate` — per the
|
||||
* KIS-TOiR optimization constraints, MCP servers must be pure, synchronous,
|
||||
* read-only validators with no external deps or CLI subprocess calls.
|
||||
*/
|
||||
|
||||
import { runMCPServer } from "./lib/mcp-server.mjs";
|
||||
|
||||
// Prisma schema constants
|
||||
const BUILTIN_TYPES = new Set([
|
||||
"String",
|
||||
"Int",
|
||||
"Float",
|
||||
"Boolean",
|
||||
"DateTime",
|
||||
"Json",
|
||||
"Bytes",
|
||||
"Decimal",
|
||||
"BigInt",
|
||||
]);
|
||||
|
||||
// Regex constants (compiled once, reused across invocations)
|
||||
const BLOCK_RE = /^\s*(model|enum|datasource|generator)\s+(\w+)\s*\{/gm;
|
||||
const INLINE_ID_RE = /@id\b/;
|
||||
const COMPOSITE_ID_RE = /@@id\s*\(/;
|
||||
const FIELD_RE = /^(\w+)\s+([\w?\[\]]+)/;
|
||||
const RELATION_RE = /@relation\b/;
|
||||
|
||||
/**
|
||||
* Extremely lightweight Prisma schema parser. Not a full grammar — it
|
||||
* recognizes top-level blocks (`model`, `enum`, `datasource`, `generator`)
|
||||
* and counts fields inside model/enum blocks by line. Sufficient for the
|
||||
* structural sanity checks the orchestrator asks for.
|
||||
*/
|
||||
function parseBlocks(schema) {
|
||||
const blocks = [];
|
||||
let m;
|
||||
while ((m = BLOCK_RE.exec(schema)) !== null) {
|
||||
const kind = m[1];
|
||||
const name = m[2];
|
||||
const openIdx = schema.indexOf("{", m.index);
|
||||
// Walk forward to find the matching close brace. Prisma schemas do not
|
||||
// nest braces inside blocks, so a simple depth counter is enough.
|
||||
let depth = 1;
|
||||
let i = openIdx + 1;
|
||||
for (; i < schema.length && depth > 0; i++) {
|
||||
if (schema[i] === "{") depth++;
|
||||
else if (schema[i] === "}") depth--;
|
||||
}
|
||||
const body = schema.slice(openIdx + 1, i - 1);
|
||||
const fieldLines = body
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith("//") && !l.startsWith("@@"));
|
||||
blocks.push({ kind, name, body, fields: fieldLines });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
const tools = [
|
||||
{
|
||||
name: "validate_schema",
|
||||
description:
|
||||
"Validate a Prisma schema string for structural soundness: required generator and datasource blocks, at least one model, each model has an @id or @@id, referenced enums exist. Read-only; never writes files.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
schema: {
|
||||
type: "string",
|
||||
description: "Full contents of schema.prisma",
|
||||
},
|
||||
},
|
||||
required: ["schema"],
|
||||
},
|
||||
execute: async ({ schema }) => {
|
||||
if (typeof schema !== "string" || schema.length === 0) {
|
||||
return { success: false, error: "schema must be a non-empty string" };
|
||||
}
|
||||
|
||||
const issues = [];
|
||||
const blocks = parseBlocks(schema);
|
||||
|
||||
const generators = blocks.filter((b) => b.kind === "generator");
|
||||
const datasources = blocks.filter((b) => b.kind === "datasource");
|
||||
const models = blocks.filter((b) => b.kind === "model");
|
||||
const enums = blocks.filter((b) => b.kind === "enum");
|
||||
|
||||
if (generators.length === 0)
|
||||
issues.push("missing generator block (expected `generator client`)");
|
||||
if (datasources.length === 0)
|
||||
issues.push("missing datasource block (expected `datasource db`)");
|
||||
if (models.length === 0) issues.push("schema declares no models");
|
||||
|
||||
const enumNames = new Set(enums.map((e) => e.name));
|
||||
|
||||
for (const model of models) {
|
||||
const hasInlineId = INLINE_ID_RE.test(model.body);
|
||||
const hasCompositeId = COMPOSITE_ID_RE.test(model.body);
|
||||
if (!hasInlineId && !hasCompositeId) {
|
||||
issues.push(`model ${model.name} has no @id or @@id directive`);
|
||||
}
|
||||
|
||||
// Detect referenced types that look like enums but are not declared.
|
||||
for (const fieldLine of model.fields) {
|
||||
// field definition: `name Type[?] ...`
|
||||
const fieldMatch = fieldLine.match(FIELD_RE);
|
||||
if (!fieldMatch) continue;
|
||||
const rawType = fieldMatch[2].replace(/[\?\[\]]/g, "");
|
||||
if (BUILTIN_TYPES.has(rawType)) continue;
|
||||
// If a type looks like an enum reference (capitalized, no model match),
|
||||
// warn when it is not found in declared models or enums.
|
||||
const isKnownModel = models.some((m) => m.name === rawType);
|
||||
const isKnownEnum = enumNames.has(rawType);
|
||||
if (!isKnownModel && !isKnownEnum) {
|
||||
// Might be a relation target defined later — only flag if it
|
||||
// looks enum-shaped (all caps first letter, no `@relation` on the line).
|
||||
if (!RELATION_RE.test(fieldLine)) {
|
||||
issues.push(
|
||||
`model ${model.name} field \`${fieldMatch[1]}\` references unknown type \`${rawType}\``
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: issues.length === 0,
|
||||
result: {
|
||||
blockCounts: {
|
||||
generator: generators.length,
|
||||
datasource: datasources.length,
|
||||
model: models.length,
|
||||
enum: enums.length,
|
||||
},
|
||||
issues,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "extract_models",
|
||||
description:
|
||||
"Extract model and enum names from a Prisma schema string, along with per-model field counts. Useful before diffing against a frozen contract.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
schema: { type: "string" },
|
||||
},
|
||||
required: ["schema"],
|
||||
},
|
||||
execute: async ({ schema }) => {
|
||||
if (typeof schema !== "string") {
|
||||
return { success: false, error: "schema must be a string" };
|
||||
}
|
||||
const blocks = parseBlocks(schema);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
models: blocks
|
||||
.filter((b) => b.kind === "model")
|
||||
.map((b) => ({ name: b.name, fieldCount: b.fields.length })),
|
||||
enums: blocks
|
||||
.filter((b) => b.kind === "enum")
|
||||
.map((b) => ({ name: b.name, valueCount: b.fields.length })),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
runMCPServer({ name: "kis-toir-prisma-validator", version: "0.1.0", tools });
|
||||
527
tools/orchestrator.ts
Normal file
527
tools/orchestrator.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* KIS-TOiR Orchestrator — Multi-Agent Coordination Engine
|
||||
*
|
||||
* This tool uses the Claude Agent SDK to coordinate specialized subagents
|
||||
* for KIS-TOiR generation workflows. The orchestrator:
|
||||
*
|
||||
* 1. Runs discovery + contract freeze via a single orchestrator prompt
|
||||
* 2. Dispatches bounded generator tasks in parallel streams
|
||||
* (Prisma / NestJS / React Admin) with graceful error isolation
|
||||
* 3. Collects per-stream outputs with write-zone + contract accountability
|
||||
* 4. Emits performance metrics for baseline-vs-optimized comparison
|
||||
*
|
||||
* The public entry point `orchestrate(prompt, options)` is preserved for
|
||||
* backward compatibility. A new `generate(contract, options)` entry point
|
||||
* exposes the structured graceful-error + parallel pipeline for callers
|
||||
* that want programmatic control over per-agent failure modes.
|
||||
*
|
||||
* USAGE:
|
||||
* npm run orchestrate "Generate Prisma schema from domain/toir.api.dsl"
|
||||
*/
|
||||
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import agents from "../agents/definitions.js";
|
||||
import { PerformanceMonitor } from "./performance-monitor.js";
|
||||
import { getProjectRoot } from "./lib/project-root.mjs";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const projectRoot = getProjectRoot();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — graceful error handling contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type GenerationStatus = "success" | "partial" | "failed";
|
||||
|
||||
export interface GenerationError {
|
||||
stage: string;
|
||||
agent: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
/**
|
||||
* Whether a partial artifact was still returned despite this error.
|
||||
* Reviewers use this flag to decide whether to attempt bounded repair.
|
||||
*/
|
||||
partialOutputAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface AgentRunResult {
|
||||
agent: string;
|
||||
stage: string;
|
||||
/** Whether this stage is allowed to fail without stopping generation. */
|
||||
critical: boolean;
|
||||
ok: boolean;
|
||||
durationMs: number;
|
||||
output?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GenerationOutput {
|
||||
status: GenerationStatus;
|
||||
runs: AgentRunResult[];
|
||||
errors: GenerationError[];
|
||||
metrics: ReturnType<PerformanceMonitor["getMetrics"]>;
|
||||
/** Wall-clock ms, end-to-end. */
|
||||
totalMs: number;
|
||||
/** Sum of per-stage durations — a conservative sequential baseline. */
|
||||
sequentialBaselineMs: number;
|
||||
/** Max per-stage duration — the best case for pure parallelism. */
|
||||
parallelLowerBoundMs: number;
|
||||
improvementPct: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Description of one generator task. The orchestrator treats each task as a
|
||||
* self-contained query() invocation and records its success/failure in
|
||||
* isolation. Critical tasks block the pipeline on failure; optional ones
|
||||
* degrade to partial outputs.
|
||||
*/
|
||||
export interface GeneratorTask {
|
||||
stage: string;
|
||||
agent: keyof typeof agents;
|
||||
critical: boolean;
|
||||
/** Human-readable task description that will be embedded in the prompt. */
|
||||
task: string;
|
||||
/** The write-zones this agent is permitted to touch. */
|
||||
writeZones: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logging helpers — 3 level logger with ISO timestamps, no external deps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type LogLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
function log(level: LogLevel, message: string): void {
|
||||
const ts = new Date().toISOString();
|
||||
const line = `[${ts}] [${level}] ${message}`;
|
||||
if (level === "ERROR") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(line);
|
||||
} else if (level === "WARN") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(line);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Low-level: run a single query() stream and return its final text result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RunQueryOptions {
|
||||
prompt: string;
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one `query()` call end-to-end and return the final text result.
|
||||
*
|
||||
* We iterate the async message stream and capture the `result` message. All
|
||||
* errors — SDK errors, tool errors, transport errors — propagate as thrown
|
||||
* exceptions so the caller's try/catch can classify them as stage failures.
|
||||
*/
|
||||
async function runQueryStream({
|
||||
prompt,
|
||||
verbose = false,
|
||||
}: RunQueryOptions): Promise<string> {
|
||||
let finalResult = "";
|
||||
|
||||
for await (const message of query({
|
||||
prompt,
|
||||
options: {
|
||||
// Agent tool must be in allowedTools for subagent delegation.
|
||||
allowedTools: ["Read", "Write", "Edit", "Bash", "Glob", "Grep", "Agent"],
|
||||
agents,
|
||||
},
|
||||
})) {
|
||||
// The SDK streams a discriminated-union message type; we duck-type the
|
||||
// few shapes we care about via `unknown` to avoid coupling to SDK
|
||||
// internals while still keeping strict-mode safety.
|
||||
const raw = message as unknown as Record<string, unknown>;
|
||||
|
||||
if (verbose && typeof raw.thinking === "string") {
|
||||
log("INFO", `💭 ${raw.thinking.substring(0, 160)}...`);
|
||||
}
|
||||
|
||||
if (verbose && raw.toolUse && typeof raw.toolUse === "object") {
|
||||
const toolUse = raw.toolUse as { name?: string };
|
||||
log("INFO", `🔧 Tool: ${toolUse.name ?? "unknown"}`);
|
||||
}
|
||||
|
||||
if ("result" in raw) {
|
||||
finalResult = String(raw.result ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-task runner — wraps runQueryStream with timing + graceful error capture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildAgentPrompt(task: GeneratorTask): string {
|
||||
// Each task receives a bounded prompt naming the target subagent, the
|
||||
// frozen-contract task text, and the explicit write-zones. The main agent
|
||||
// is expected to delegate to the matching subagent via the Agent tool.
|
||||
return `You are the KIS-TOiR orchestrator running a single bounded generator task.
|
||||
|
||||
Target subagent: ${task.agent}
|
||||
Stage: ${task.stage}
|
||||
Write-zones (STRICT — violations must be rejected):
|
||||
${task.writeZones.map((z) => ` - ${z}`).join("\n")}
|
||||
|
||||
Task (from frozen contract):
|
||||
${task.task}
|
||||
|
||||
Delegation rules:
|
||||
- Use the ${task.agent} agent via the Agent tool for all code generation.
|
||||
- Do not touch files outside the declared write-zones.
|
||||
- Report back a short summary of what was changed and any blockers.
|
||||
- If you cannot complete the task, return a clear error message — do NOT
|
||||
fabricate partial success.`;
|
||||
}
|
||||
|
||||
async function runTask(
|
||||
task: GeneratorTask,
|
||||
monitor: PerformanceMonitor,
|
||||
verbose: boolean
|
||||
): Promise<AgentRunResult> {
|
||||
const started = Date.now();
|
||||
log("INFO", `▶️ stage=${task.stage} agent=${task.agent} critical=${task.critical}`);
|
||||
|
||||
try {
|
||||
const output = await runQueryStream({
|
||||
prompt: buildAgentPrompt(task),
|
||||
verbose,
|
||||
});
|
||||
const durationMs = Date.now() - started;
|
||||
monitor.record(task.stage, durationMs);
|
||||
log("INFO", `✅ stage=${task.stage} completed in ${durationMs}ms`);
|
||||
return {
|
||||
agent: task.agent,
|
||||
stage: task.stage,
|
||||
critical: task.critical,
|
||||
ok: true,
|
||||
durationMs,
|
||||
output,
|
||||
};
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - started;
|
||||
monitor.record(`${task.stage} (failed)`, durationMs);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log("ERROR", `❌ stage=${task.stage} failed after ${durationMs}ms: ${message}`);
|
||||
return {
|
||||
agent: task.agent,
|
||||
stage: task.stage,
|
||||
critical: task.critical,
|
||||
ok: false,
|
||||
durationMs,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// High-level: structured parallel generation with graceful error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GenerateOptions {
|
||||
verbose?: boolean;
|
||||
/**
|
||||
* When true, run tasks sequentially instead of in parallel. Primarily used
|
||||
* to capture the baseline timing for speedup comparisons.
|
||||
*/
|
||||
sequential?: boolean;
|
||||
/**
|
||||
* Destination for the metrics JSON report. Defaults to
|
||||
* `<projectRoot>/generation-metrics.json`.
|
||||
*/
|
||||
metricsPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured parallel generation pipeline.
|
||||
*
|
||||
* Critical tasks (Prisma, NestJS) block the pipeline on failure; optional
|
||||
* tasks (React Admin, data-access) degrade to partial outputs. Regardless of
|
||||
* outcome, metrics are written to disk and a full GenerationOutput is
|
||||
* returned so callers can run bounded repair or surface the failure.
|
||||
*/
|
||||
export async function generate(
|
||||
tasks: GeneratorTask[],
|
||||
options: GenerateOptions = {}
|
||||
): Promise<GenerationOutput> {
|
||||
const { verbose = false, sequential = false } = options;
|
||||
const monitor = new PerformanceMonitor();
|
||||
const wallStart = Date.now();
|
||||
|
||||
log("INFO", `starting generation: tasks=${tasks.length} mode=${sequential ? "sequential" : "parallel"}`);
|
||||
|
||||
let runs: AgentRunResult[];
|
||||
|
||||
if (sequential) {
|
||||
// Baseline path — useful for "measure first" speedup comparisons.
|
||||
runs = [];
|
||||
for (const task of tasks) {
|
||||
// Short-circuit on critical failure to match real-world sequential behaviour.
|
||||
const result = await runTask(task, monitor, verbose);
|
||||
runs.push(result);
|
||||
if (!result.ok && result.critical) {
|
||||
log("WARN", `aborting sequential run after critical failure: ${result.stage}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Parallel path — Promise.allSettled is mandatory here: Promise.all would
|
||||
// reject on the first failure and lose partial outputs from siblings.
|
||||
// Since each runTask already catches its own errors, all outcomes arrive
|
||||
// as fulfilled promises, but allSettled keeps us safe against bugs.
|
||||
const settled = await Promise.allSettled(
|
||||
tasks.map((task) => runTask(task, monitor, verbose))
|
||||
);
|
||||
runs = settled.map((s, i) => {
|
||||
if (s.status === "fulfilled") return s.value;
|
||||
const task = tasks[i];
|
||||
const message = s.reason instanceof Error ? s.reason.message : String(s.reason);
|
||||
log("ERROR", `unexpected rejection for stage=${task.stage}: ${message}`);
|
||||
return {
|
||||
agent: task.agent,
|
||||
stage: task.stage,
|
||||
critical: task.critical,
|
||||
ok: false,
|
||||
durationMs: 0,
|
||||
error: message,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const totalMs = Date.now() - wallStart;
|
||||
|
||||
// ---- Classify outcome ------------------------------------------------
|
||||
const criticalFailures = runs.filter((r) => !r.ok && r.critical);
|
||||
const optionalFailures = runs.filter((r) => !r.ok && !r.critical);
|
||||
const anyFailure = criticalFailures.length + optionalFailures.length > 0;
|
||||
|
||||
let status: GenerationStatus;
|
||||
if (criticalFailures.length > 0) {
|
||||
status = "failed";
|
||||
} else if (optionalFailures.length > 0) {
|
||||
status = "partial";
|
||||
} else {
|
||||
status = "success";
|
||||
}
|
||||
|
||||
const errors: GenerationError[] = runs
|
||||
.filter((r) => !r.ok)
|
||||
.map((r) => ({
|
||||
stage: r.stage,
|
||||
agent: r.agent,
|
||||
message: r.error ?? "unknown error",
|
||||
timestamp: new Date().toISOString(),
|
||||
partialOutputAvailable: !r.critical,
|
||||
}));
|
||||
|
||||
// ---- Speedup accounting ---------------------------------------------
|
||||
// Sequential baseline = sum of per-stage durations (what this would have
|
||||
// cost if we ran each stage one after another).
|
||||
// Parallel lower bound = the longest single stage (best case for pure
|
||||
// parallel execution). `totalMs` is the actual wall-clock observed.
|
||||
const sequentialBaselineMs = runs.reduce((acc, r) => acc + r.durationMs, 0);
|
||||
const parallelLowerBoundMs = runs.reduce(
|
||||
(acc, r) => Math.max(acc, r.durationMs),
|
||||
0
|
||||
);
|
||||
const improvementPct =
|
||||
sequentialBaselineMs > 0
|
||||
? ((sequentialBaselineMs - totalMs) / sequentialBaselineMs) * 100
|
||||
: 0;
|
||||
|
||||
monitor.summary(sequential ? "Sequential baseline" : "Parallel generation");
|
||||
|
||||
// ---- Persist metrics report -----------------------------------------
|
||||
const metricsPath =
|
||||
options.metricsPath ?? path.join(projectRoot, "generation-metrics.json");
|
||||
const metricsSnapshot = monitor.getMetrics();
|
||||
try {
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
mode: sequential ? "sequential" : "parallel",
|
||||
status,
|
||||
totalMs,
|
||||
sequentialBaselineMs,
|
||||
parallelLowerBoundMs,
|
||||
improvementPct: Number(improvementPct.toFixed(2)),
|
||||
runs: runs.map(({ output: _output, ...rest }) => rest),
|
||||
errors,
|
||||
metrics: metricsSnapshot,
|
||||
};
|
||||
fs.writeFileSync(metricsPath, JSON.stringify(report, null, 2));
|
||||
log("INFO", `metrics written to ${metricsPath}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log("WARN", `could not write metrics report: ${message}`);
|
||||
}
|
||||
|
||||
if (status === "success") {
|
||||
log("INFO", `🎉 generation completed successfully in ${totalMs}ms`);
|
||||
} else if (status === "partial") {
|
||||
log(
|
||||
"WARN",
|
||||
`generation completed with ${optionalFailures.length} optional failure(s) in ${totalMs}ms — partial outputs returned`
|
||||
);
|
||||
} else {
|
||||
log(
|
||||
"ERROR",
|
||||
`generation FAILED with ${criticalFailures.length} critical failure(s) in ${totalMs}ms`
|
||||
);
|
||||
}
|
||||
|
||||
if (anyFailure && verbose) {
|
||||
for (const err of errors) {
|
||||
log("WARN", ` ${err.stage} (${err.agent}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
runs,
|
||||
errors,
|
||||
metrics: metricsSnapshot,
|
||||
totalMs,
|
||||
sequentialBaselineMs,
|
||||
parallelLowerBoundMs,
|
||||
improvementPct,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward-compatible monolithic entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OrchestrateOptions {
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-prompt orchestration. This is the original entry point — it runs
|
||||
* one `query()` stream and lets the main Claude agent decide when to delegate
|
||||
* to subagents via the Agent tool. Preserved for backward compatibility with
|
||||
* existing scripts that call `orchestrate(prompt)`.
|
||||
*
|
||||
* Prefer `generate(tasks, options)` when you need programmatic per-agent
|
||||
* failure isolation, parallel execution, or structured metrics.
|
||||
*/
|
||||
export async function orchestrate(
|
||||
mainPrompt: string,
|
||||
options?: OrchestrateOptions
|
||||
): Promise<string> {
|
||||
const verbose = options?.verbose ?? false;
|
||||
const monitor = new PerformanceMonitor();
|
||||
|
||||
if (verbose) {
|
||||
log("INFO", `project root: ${projectRoot}`);
|
||||
log("INFO", `agents available: ${Object.keys(agents).length}`);
|
||||
}
|
||||
|
||||
log("INFO", "🎯 orchestration task:");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(mainPrompt);
|
||||
log("INFO", "📡 starting agent coordination");
|
||||
|
||||
monitor.mark("orchestrate-start");
|
||||
|
||||
try {
|
||||
const result = await runQueryStream({
|
||||
prompt: `You are the KIS-TOiR master orchestrator for Claude Code.
|
||||
|
||||
Your role:
|
||||
1. Read AGENTS.md, prompts/general-prompt.md, and relevant companion docs
|
||||
2. Understand the current workspace state via the explorer agent if needed
|
||||
3. Verify framework assumptions via docs_researcher if needed
|
||||
4. Freeze a structured contract before specialized generation
|
||||
5. Delegate bounded work to specialized agents (generator_prisma, generator_nest_resources, generator_react_admin_resources, generator_data_access, reviewer)
|
||||
6. Accept or reject delegated outputs based on write-zone compliance and contract adherence
|
||||
7. Integrate accepted outputs and run validation gates
|
||||
8. Report completion only when both builds pass and evaluation gates succeed
|
||||
|
||||
Available agents (use by description match):
|
||||
- explorer: for discovery and codebase exploration
|
||||
- docs_researcher: for framework verification
|
||||
- generator_prisma: for Prisma schema generation (after contract freeze)
|
||||
- generator_nest_resources: for NestJS backend generation (after contract freeze)
|
||||
- generator_react_admin_resources: for React Admin frontend generation (after contract freeze)
|
||||
- generator_data_access: for frontend data-access integration (after contract freeze)
|
||||
- reviewer: for final review (after validation)
|
||||
|
||||
Mandatory delegation rules:
|
||||
- Do NOT generate anything yourself; use agents for specialized tasks
|
||||
- Contract freeze must be explicit before generator delegation
|
||||
- Accept/reject delegated outputs explicitly
|
||||
- Write-zones are strictly enforced per agent (see .claude/CLAUDE.md)
|
||||
- Validation gates are non-optional proof points
|
||||
|
||||
Your task:
|
||||
${mainPrompt}`,
|
||||
verbose,
|
||||
});
|
||||
|
||||
monitor.measure("orchestrate-total", "orchestrate-start");
|
||||
monitor.summary("Orchestration");
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("\n=== ORCHESTRATION COMPLETE ===\n");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
monitor.measure("orchestrate-total (failed)", "orchestrate-start");
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log("ERROR", `orchestration failed: ${message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Usage: orchestrator.ts <task-description>");
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Example: orchestrator.ts "Generate Prisma schema"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const task = args.filter((a: string) => !a.startsWith("--")).join(" ");
|
||||
const verbose = args.includes("--verbose");
|
||||
|
||||
try {
|
||||
await orchestrate(task, { verbose });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log("ERROR", `orchestration failed: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute if this is the main module
|
||||
if (
|
||||
(import.meta as unknown as { main?: boolean }).main ||
|
||||
process.argv[1] === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
void main();
|
||||
}
|
||||
|
||||
export default orchestrate;
|
||||
169
tools/performance-monitor.ts
Normal file
169
tools/performance-monitor.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* KIS-TOiR Performance Monitor
|
||||
*
|
||||
* Lightweight instrumentation for the orchestrator. Tracks wall-clock time
|
||||
* between named marks, accumulates metrics, prints human-readable summaries,
|
||||
* and exports to JSON/CSV for trend analysis.
|
||||
*
|
||||
* Usage:
|
||||
* import { monitor } from './performance-monitor';
|
||||
* monitor.mark('start');
|
||||
* // ... work ...
|
||||
* monitor.measure('prisma');
|
||||
* monitor.summary('Full Generation');
|
||||
* fs.writeFileSync('generation-metrics.json', monitor.export('json'));
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
export interface MetricEntry {
|
||||
label: string;
|
||||
duration: number; // milliseconds
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export type ExportFormat = "json" | "csv";
|
||||
|
||||
export class PerformanceMonitor {
|
||||
private marks: Map<string, number> = new Map();
|
||||
private metrics: MetricEntry[] = [];
|
||||
private readonly startTime: number;
|
||||
|
||||
constructor() {
|
||||
this.startTime = Date.now();
|
||||
this.marks.set("start", this.startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a named mark at the current time. A mark is an instant, not a
|
||||
* duration. Use mark() to set a baseline, then measure() to record elapsed
|
||||
* time from that baseline.
|
||||
*/
|
||||
mark(label: string): void {
|
||||
this.marks.set(label, Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the elapsed time from the last `start` (or prior matching mark)
|
||||
* until now. Returns the duration in milliseconds and stores it in the
|
||||
* metrics collection. Pass `sinceMark` to measure from a specific mark
|
||||
* rather than the most recent one.
|
||||
*/
|
||||
measure(label: string, sinceMark = "start"): number {
|
||||
const origin = this.marks.get(sinceMark) ?? this.startTime;
|
||||
const duration = Date.now() - origin;
|
||||
this.metrics.push({
|
||||
label,
|
||||
duration,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`⏱️ ${label}: ${duration.toFixed(2)}ms`);
|
||||
return duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually add a metric entry. Useful for tasks where you already have the
|
||||
* duration (e.g., measured inside Promise.all bookkeeping).
|
||||
*/
|
||||
record(label: string, duration: number): void {
|
||||
this.metrics.push({
|
||||
label,
|
||||
duration,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`⏱️ ${label}: ${duration.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a summary block. If `label` is provided, compares the max metric
|
||||
* (assumed parallel total) against the sum of metrics (sequential baseline)
|
||||
* and reports the speedup.
|
||||
*/
|
||||
summary(label = "Summary"): void {
|
||||
const total = Date.now() - this.startTime;
|
||||
const seqBaseline = this.metrics.reduce((acc, m) => acc + m.duration, 0);
|
||||
const parallelActual = this.metrics.reduce(
|
||||
(acc, m) => Math.max(acc, m.duration),
|
||||
0
|
||||
);
|
||||
const improvement =
|
||||
seqBaseline > 0
|
||||
? ((seqBaseline - parallelActual) / seqBaseline) * 100
|
||||
: 0;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`\n📊 ${label}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` Total wall-clock: ${total.toFixed(2)}ms`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` Sum of phases: ${seqBaseline.toFixed(2)}ms (sequential baseline)`);
|
||||
if (this.metrics.length > 1) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
` Longest phase: ${parallelActual.toFixed(2)}ms (parallel lower bound)`
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` Speedup achieved: ${improvement.toFixed(1)}%`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize metrics for disk storage / trend analysis.
|
||||
*/
|
||||
export(format: ExportFormat = "json"): string {
|
||||
if (format === "csv") {
|
||||
const header = "label,duration_ms,timestamp";
|
||||
const rows = this.metrics.map(
|
||||
(m) => `${m.label},${m.duration},${m.timestamp.toISOString()}`
|
||||
);
|
||||
return [header, ...rows].join("\n");
|
||||
}
|
||||
return JSON.stringify(
|
||||
{
|
||||
startedAt: new Date(this.startTime).toISOString(),
|
||||
totalMs: Date.now() - this.startTime,
|
||||
metrics: this.metrics,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write metrics to a file alongside other generation outputs. Defaults to
|
||||
* project-root `generation-metrics.json`.
|
||||
*/
|
||||
writeReport(filePath = "generation-metrics.json"): void {
|
||||
const resolved = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.join(process.cwd(), filePath);
|
||||
fs.writeFileSync(resolved, this.export("json"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a shallow copy of the recorded metrics. Useful for tests and for
|
||||
* attaching to GenerationOutput results.
|
||||
*/
|
||||
getMetrics(): MetricEntry[] {
|
||||
return [...this.metrics];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all marks and metrics. The monitor remains usable afterward.
|
||||
*/
|
||||
reset(): void {
|
||||
this.marks.clear();
|
||||
this.metrics.length = 0;
|
||||
this.marks.set("start", Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared singleton instance for convenience. Multiple independent runs can
|
||||
* call `monitor.reset()` between them.
|
||||
*/
|
||||
export const monitor = new PerformanceMonitor();
|
||||
export default monitor;
|
||||
Reference in New Issue
Block a user