rebase generation

This commit is contained in:
MaKarin
2026-04-07 19:40:41 +03:00
parent 73ddb1a948
commit aab7bfa691
180 changed files with 15512 additions and 364 deletions

View 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, "..", ".."));
}

View 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);
});
}

View 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
View 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 });

View 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
View 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;

View 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;