@kody-ade/engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -0
  3. package/dist/agent-runner.d.ts +4 -0
  4. package/dist/agent-runner.js +122 -0
  5. package/dist/bin/cli.js +11276 -0
  6. package/dist/ci/parse-inputs.d.ts +6 -0
  7. package/dist/ci/parse-inputs.js +76 -0
  8. package/dist/ci/parse-safety.d.ts +6 -0
  9. package/dist/ci/parse-safety.js +22 -0
  10. package/dist/cli/args.d.ts +13 -0
  11. package/dist/cli/args.js +42 -0
  12. package/dist/cli/litellm.d.ts +2 -0
  13. package/dist/cli/litellm.js +85 -0
  14. package/dist/cli/task-resolution.d.ts +2 -0
  15. package/dist/cli/task-resolution.js +41 -0
  16. package/dist/config.d.ts +49 -0
  17. package/dist/config.js +72 -0
  18. package/dist/context.d.ts +4 -0
  19. package/dist/context.js +83 -0
  20. package/dist/definitions.d.ts +3 -0
  21. package/dist/definitions.js +59 -0
  22. package/dist/entry.d.ts +1 -0
  23. package/dist/entry.js +236 -0
  24. package/dist/git-utils.d.ts +13 -0
  25. package/dist/git-utils.js +174 -0
  26. package/dist/github-api.d.ts +14 -0
  27. package/dist/github-api.js +114 -0
  28. package/dist/kody-utils.d.ts +1 -0
  29. package/dist/kody-utils.js +9 -0
  30. package/dist/learning/auto-learn.d.ts +2 -0
  31. package/dist/learning/auto-learn.js +169 -0
  32. package/dist/logger.d.ts +14 -0
  33. package/dist/logger.js +51 -0
  34. package/dist/memory.d.ts +1 -0
  35. package/dist/memory.js +20 -0
  36. package/dist/observer.d.ts +9 -0
  37. package/dist/observer.js +80 -0
  38. package/dist/pipeline/complexity.d.ts +3 -0
  39. package/dist/pipeline/complexity.js +12 -0
  40. package/dist/pipeline/executor-registry.d.ts +3 -0
  41. package/dist/pipeline/executor-registry.js +20 -0
  42. package/dist/pipeline/hooks.d.ts +17 -0
  43. package/dist/pipeline/hooks.js +110 -0
  44. package/dist/pipeline/questions.d.ts +2 -0
  45. package/dist/pipeline/questions.js +44 -0
  46. package/dist/pipeline/runner-selection.d.ts +2 -0
  47. package/dist/pipeline/runner-selection.js +13 -0
  48. package/dist/pipeline/state.d.ts +4 -0
  49. package/dist/pipeline/state.js +37 -0
  50. package/dist/pipeline.d.ts +3 -0
  51. package/dist/pipeline.js +213 -0
  52. package/dist/preflight.d.ts +1 -0
  53. package/dist/preflight.js +69 -0
  54. package/dist/retrospective.d.ts +26 -0
  55. package/dist/retrospective.js +211 -0
  56. package/dist/stages/agent.d.ts +2 -0
  57. package/dist/stages/agent.js +94 -0
  58. package/dist/stages/gate.d.ts +2 -0
  59. package/dist/stages/gate.js +32 -0
  60. package/dist/stages/review.d.ts +2 -0
  61. package/dist/stages/review.js +32 -0
  62. package/dist/stages/ship.d.ts +3 -0
  63. package/dist/stages/ship.js +154 -0
  64. package/dist/stages/verify.d.ts +2 -0
  65. package/dist/stages/verify.js +94 -0
  66. package/dist/types.d.ts +61 -0
  67. package/dist/types.js +1 -0
  68. package/dist/validators.d.ts +8 -0
  69. package/dist/validators.js +42 -0
  70. package/dist/verify-runner.d.ts +11 -0
  71. package/dist/verify-runner.js +110 -0
  72. package/kody.config.schema.json +299 -0
  73. package/package.json +39 -0
  74. package/prompts/autofix.md +52 -0
  75. package/prompts/build.md +26 -0
  76. package/prompts/decompose.md +77 -0
  77. package/prompts/plan.md +65 -0
  78. package/prompts/review-fix.md +27 -0
  79. package/prompts/review.md +115 -0
  80. package/prompts/taskify-ticket.md +122 -0
  81. package/prompts/taskify.md +70 -0
  82. package/templates/kody-watch.yml +57 -0
  83. package/templates/kody.yml +450 -0
  84. package/templates/watch-agents/branch-cleanup/agent.json +7 -0
  85. package/templates/watch-agents/branch-cleanup/agent.md +13 -0
  86. package/templates/watch-agents/dependency-checker/agent.json +7 -0
  87. package/templates/watch-agents/dependency-checker/agent.md +14 -0
  88. package/templates/watch-agents/readme-health/agent.json +7 -0
  89. package/templates/watch-agents/readme-health/agent.md +17 -0
  90. package/templates/watch-agents/stale-pr-reviewer/agent.json +7 -0
  91. package/templates/watch-agents/stale-pr-reviewer/agent.md +13 -0
  92. package/templates/watch-agents/todo-scanner/agent.json +7 -0
  93. package/templates/watch-agents/todo-scanner/agent.md +10 -0
@@ -0,0 +1,32 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { STAGES } from "../definitions.js";
4
+ import { logger } from "../logger.js";
5
+ import { executeAgentStage } from "./agent.js";
6
+ export async function executeReviewWithFix(ctx, def) {
7
+ if (ctx.input.dryRun) {
8
+ return { outcome: "completed", retries: 0 };
9
+ }
10
+ const reviewDef = STAGES.find((s) => s.name === "review");
11
+ const reviewFixDef = STAGES.find((s) => s.name === "review-fix");
12
+ const reviewResult = await executeAgentStage(ctx, reviewDef);
13
+ if (reviewResult.outcome !== "completed") {
14
+ return reviewResult;
15
+ }
16
+ const reviewFile = path.join(ctx.taskDir, "review.md");
17
+ if (!fs.existsSync(reviewFile)) {
18
+ return { outcome: "failed", retries: 0, error: "review.md not found" };
19
+ }
20
+ const content = fs.readFileSync(reviewFile, "utf-8");
21
+ const hasIssues = /\bfail\b/i.test(content) && !/pass/i.test(content);
22
+ if (!hasIssues) {
23
+ return reviewResult;
24
+ }
25
+ logger.info(` review found issues, running review-fix...`);
26
+ const fixResult = await executeAgentStage(ctx, reviewFixDef);
27
+ if (fixResult.outcome !== "completed") {
28
+ return fixResult;
29
+ }
30
+ logger.info(` re-running review after fix...`);
31
+ return executeAgentStage(ctx, reviewDef);
32
+ }
@@ -0,0 +1,3 @@
1
+ import type { StageDefinition, StageResult, PipelineContext } from "../types.js";
2
+ export declare function buildPrBody(ctx: PipelineContext): string;
3
+ export declare function executeShipStage(ctx: PipelineContext, _def: StageDefinition): StageResult;
@@ -0,0 +1,154 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { execFileSync } from "child_process";
4
+ import { getCurrentBranch, getDefaultBranch, pushBranch, } from "../git-utils.js";
5
+ import { postComment, createPR, } from "../github-api.js";
6
+ import { getProjectConfig } from "../config.js";
7
+ export function buildPrBody(ctx) {
8
+ const sections = [];
9
+ // What and why — from task.json
10
+ const taskJsonPath = path.join(ctx.taskDir, "task.json");
11
+ if (fs.existsSync(taskJsonPath)) {
12
+ try {
13
+ const raw = fs.readFileSync(taskJsonPath, "utf-8");
14
+ const cleaned = raw.replace(/^```json\s*\n?/m, "").replace(/\n?```\s*$/m, "");
15
+ const task = JSON.parse(cleaned);
16
+ if (task.description) {
17
+ sections.push(`## What\n\n${task.description}`);
18
+ }
19
+ if (task.scope?.length) {
20
+ sections.push(`\n## Scope\n\n${task.scope.map((s) => `- \`${s}\``).join("\n")}`);
21
+ }
22
+ sections.push(`\n**Type:** ${task.task_type ?? "unknown"} | **Risk:** ${task.risk_level ?? "unknown"}`);
23
+ }
24
+ catch { /* ignore parse errors */ }
25
+ }
26
+ // Changes — from review.md summary
27
+ const reviewPath = path.join(ctx.taskDir, "review.md");
28
+ if (fs.existsSync(reviewPath)) {
29
+ const review = fs.readFileSync(reviewPath, "utf-8");
30
+ const summaryMatch = review.match(/## Summary\s*\n([\s\S]*?)(?=\n## |\n*$)/);
31
+ if (summaryMatch) {
32
+ const summary = summaryMatch[1].trim();
33
+ if (summary) {
34
+ sections.push(`\n## Changes\n\n${summary}`);
35
+ }
36
+ }
37
+ const verdictMatch = review.match(/## Verdict:\s*(PASS|FAIL)/i);
38
+ if (verdictMatch) {
39
+ sections.push(`\n**Review:** ${verdictMatch[1].toUpperCase() === "PASS" ? "✅ PASS" : "❌ FAIL"}`);
40
+ }
41
+ }
42
+ // Verify result
43
+ const verifyPath = path.join(ctx.taskDir, "verify.md");
44
+ if (fs.existsSync(verifyPath)) {
45
+ const verify = fs.readFileSync(verifyPath, "utf-8");
46
+ if (/PASS/i.test(verify))
47
+ sections.push(`**Verify:** ✅ typecheck + tests + lint passed`);
48
+ }
49
+ // Plan — collapsible details
50
+ const planPath = path.join(ctx.taskDir, "plan.md");
51
+ if (fs.existsSync(planPath)) {
52
+ const plan = fs.readFileSync(planPath, "utf-8").trim();
53
+ if (plan) {
54
+ const truncated = plan.length > 800 ? plan.slice(0, 800) + "\n..." : plan;
55
+ sections.push(`\n<details><summary>📋 Implementation plan</summary>\n\n${truncated}\n</details>`);
56
+ }
57
+ }
58
+ // Closes issue
59
+ if (ctx.input.issueNumber) {
60
+ sections.push(`\nCloses #${ctx.input.issueNumber}`);
61
+ }
62
+ sections.push(`\n---\n🤖 Generated by Kody`);
63
+ return sections.join("\n");
64
+ }
65
+ export function executeShipStage(ctx, _def) {
66
+ const shipPath = path.join(ctx.taskDir, "ship.md");
67
+ if (ctx.input.dryRun) {
68
+ fs.writeFileSync(shipPath, "# Ship\n\nShip stage skipped — dry run.\n");
69
+ return { outcome: "completed", outputFile: "ship.md", retries: 0 };
70
+ }
71
+ // Local mode or no issue: skip git push + PR
72
+ if (ctx.input.local && !ctx.input.issueNumber) {
73
+ fs.writeFileSync(shipPath, "# Ship\n\nShip stage skipped — local mode, no issue number.\n");
74
+ return { outcome: "completed", outputFile: "ship.md", retries: 0 };
75
+ }
76
+ try {
77
+ const head = getCurrentBranch(ctx.projectDir);
78
+ const base = getDefaultBranch(ctx.projectDir);
79
+ pushBranch(ctx.projectDir);
80
+ // Resolve owner/repo
81
+ const config = getProjectConfig();
82
+ let owner = config.github?.owner;
83
+ let repo = config.github?.repo;
84
+ if (!owner || !repo) {
85
+ try {
86
+ const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
87
+ encoding: "utf-8",
88
+ cwd: ctx.projectDir,
89
+ }).trim();
90
+ const match = remoteUrl.match(/github\.com[/:]([^/]+)\/([^/.]+)/);
91
+ if (match) {
92
+ owner = match[1];
93
+ repo = match[2];
94
+ }
95
+ }
96
+ catch {
97
+ // Can't determine repo
98
+ }
99
+ }
100
+ // Derive PR title from task.json (preferred) or task.md (fallback)
101
+ let title = "Update";
102
+ const TYPE_PREFIX = {
103
+ feature: "feat",
104
+ bugfix: "fix",
105
+ refactor: "refactor",
106
+ docs: "docs",
107
+ chore: "chore",
108
+ };
109
+ const taskJsonPath = path.join(ctx.taskDir, "task.json");
110
+ if (fs.existsSync(taskJsonPath)) {
111
+ try {
112
+ const raw = fs.readFileSync(taskJsonPath, "utf-8");
113
+ const cleaned = raw.replace(/^```json\s*\n?/m, "").replace(/\n?```\s*$/m, "");
114
+ const task = JSON.parse(cleaned);
115
+ const prefix = TYPE_PREFIX[task.task_type] ?? "chore";
116
+ const taskTitle = task.title ?? "Update";
117
+ title = `${prefix}: ${taskTitle}`.slice(0, 72);
118
+ }
119
+ catch { /* fallback below */ }
120
+ }
121
+ if (title === "Update") {
122
+ const taskMdPath = path.join(ctx.taskDir, "task.md");
123
+ if (fs.existsSync(taskMdPath)) {
124
+ const content = fs.readFileSync(taskMdPath, "utf-8");
125
+ const firstLine = content.split("\n").find((l) => l.trim() && !l.startsWith("#") && !l.startsWith("*"));
126
+ if (firstLine)
127
+ title = `chore: ${firstLine.trim()}`.slice(0, 72);
128
+ }
129
+ }
130
+ // Build rich PR body
131
+ const body = buildPrBody(ctx);
132
+ const pr = createPR(head, base, title, body);
133
+ if (pr) {
134
+ if (ctx.input.issueNumber && !ctx.input.local) {
135
+ try {
136
+ postComment(ctx.input.issueNumber, `🎉 PR created: ${pr.url}`);
137
+ }
138
+ catch {
139
+ // Fire and forget
140
+ }
141
+ }
142
+ fs.writeFileSync(shipPath, `# Ship\n\nPR created: ${pr.url}\nPR #${pr.number}\n`);
143
+ }
144
+ else {
145
+ fs.writeFileSync(shipPath, "# Ship\n\nPushed branch but failed to create PR.\n");
146
+ }
147
+ return { outcome: "completed", outputFile: "ship.md", retries: 0 };
148
+ }
149
+ catch (err) {
150
+ const msg = err instanceof Error ? err.message : String(err);
151
+ fs.writeFileSync(shipPath, `# Ship\n\nFailed: ${msg}\n`);
152
+ return { outcome: "failed", retries: 0, error: msg };
153
+ }
154
+ }
@@ -0,0 +1,2 @@
1
+ import type { StageDefinition, StageResult, PipelineContext } from "../types.js";
2
+ export declare function executeVerifyWithAutofix(ctx: PipelineContext, def: StageDefinition): Promise<StageResult>;
@@ -0,0 +1,94 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { execFileSync } from "child_process";
4
+ import { resolveModel } from "../context.js";
5
+ import { getProjectConfig, FIX_COMMAND_TIMEOUT_MS } from "../config.js";
6
+ import { parseCommand } from "../verify-runner.js";
7
+ import { getRunnerForStage } from "../pipeline/runner-selection.js";
8
+ import { postComment } from "../github-api.js";
9
+ import { diagnoseFailure, getModifiedFiles } from "../observer.js";
10
+ import { logger } from "../logger.js";
11
+ import { executeAgentStage } from "./agent.js";
12
+ import { executeGateStage } from "./gate.js";
13
+ export async function executeVerifyWithAutofix(ctx, def) {
14
+ const maxAttempts = def.maxRetries ?? 2;
15
+ for (let attempt = 0; attempt <= maxAttempts; attempt++) {
16
+ logger.info(` verification attempt ${attempt + 1}/${maxAttempts + 1}`);
17
+ const gateResult = executeGateStage(ctx, def);
18
+ if (gateResult.outcome === "completed") {
19
+ return { ...gateResult, retries: attempt };
20
+ }
21
+ if (attempt < maxAttempts) {
22
+ // Read verify errors for diagnosis
23
+ const verifyPath = path.join(ctx.taskDir, "verify.md");
24
+ const errorOutput = fs.existsSync(verifyPath) ? fs.readFileSync(verifyPath, "utf-8") : "Unknown error";
25
+ // AI diagnosis — classify the failure
26
+ const modifiedFiles = getModifiedFiles(ctx.projectDir);
27
+ const defaultRunner = getRunnerForStage(ctx, "taskify"); // use cheap model
28
+ const diagnosis = await diagnoseFailure("verify", errorOutput, modifiedFiles, defaultRunner, resolveModel("cheap"));
29
+ if (diagnosis.classification === "infrastructure") {
30
+ logger.warn(` Infrastructure issue: ${diagnosis.reason}`);
31
+ if (ctx.input.issueNumber && !ctx.input.local) {
32
+ try {
33
+ postComment(ctx.input.issueNumber, `⚠️ **Infrastructure issue detected:** ${diagnosis.reason}\n\n${diagnosis.resolution}`);
34
+ }
35
+ catch { /* fire-and-forget */ }
36
+ }
37
+ return { outcome: "completed", retries: attempt, error: `Skipped: ${diagnosis.reason}` };
38
+ }
39
+ if (diagnosis.classification === "pre-existing") {
40
+ logger.warn(` Pre-existing issue: ${diagnosis.reason}`);
41
+ return { outcome: "completed", retries: attempt, error: `Skipped: ${diagnosis.reason}` };
42
+ }
43
+ if (diagnosis.classification === "abort") {
44
+ logger.error(` Unrecoverable: ${diagnosis.reason}`);
45
+ return { outcome: "failed", retries: attempt, error: diagnosis.reason };
46
+ }
47
+ // fixable or retry — proceed with autofix
48
+ logger.info(` Diagnosis: ${diagnosis.classification} — ${diagnosis.reason}`);
49
+ const config = getProjectConfig();
50
+ const runFix = (cmd) => {
51
+ if (!cmd)
52
+ return;
53
+ const parts = parseCommand(cmd);
54
+ if (parts.length === 0)
55
+ return;
56
+ try {
57
+ execFileSync(parts[0], parts.slice(1), {
58
+ stdio: "pipe",
59
+ timeout: FIX_COMMAND_TIMEOUT_MS,
60
+ });
61
+ }
62
+ catch {
63
+ // Silently ignore fix failures
64
+ }
65
+ };
66
+ runFix(config.quality.lintFix);
67
+ runFix(config.quality.formatFix);
68
+ if (def.retryWithAgent) {
69
+ // Create new context with diagnosis guidance — don't mutate original
70
+ const autofixCtx = {
71
+ ...ctx,
72
+ input: {
73
+ ...ctx.input,
74
+ feedback: `${diagnosis.resolution}\n\n${ctx.input.feedback ?? ""}`.trim(),
75
+ },
76
+ };
77
+ logger.info(` running ${def.retryWithAgent} agent with diagnosis guidance...`);
78
+ await executeAgentStage(autofixCtx, {
79
+ ...def,
80
+ name: def.retryWithAgent,
81
+ type: "agent",
82
+ modelTier: "mid",
83
+ timeout: 300_000,
84
+ outputFile: undefined,
85
+ });
86
+ }
87
+ }
88
+ }
89
+ return {
90
+ outcome: "failed",
91
+ retries: maxAttempts,
92
+ error: "Verification failed after autofix attempts",
93
+ };
94
+ }
@@ -0,0 +1,61 @@
1
+ export type StageName = "taskify" | "plan" | "build" | "verify" | "review" | "review-fix" | "ship";
2
+ export type StageType = "agent" | "gate" | "deterministic";
3
+ export type PipelineState = "pending" | "running" | "completed" | "failed" | "timeout";
4
+ export interface StageDefinition {
5
+ name: StageName;
6
+ type: StageType;
7
+ modelTier: "cheap" | "mid" | "strong";
8
+ timeout: number;
9
+ maxRetries: number;
10
+ outputFile?: string;
11
+ retryWithAgent?: string;
12
+ }
13
+ export interface StageState {
14
+ state: PipelineState;
15
+ startedAt?: string;
16
+ completedAt?: string;
17
+ retries: number;
18
+ error?: string;
19
+ outputFile?: string;
20
+ }
21
+ export interface PipelineStatus {
22
+ taskId: string;
23
+ state: "running" | "completed" | "failed";
24
+ stages: Record<StageName, StageState>;
25
+ createdAt: string;
26
+ updatedAt: string;
27
+ }
28
+ export interface StageResult {
29
+ outcome: "completed" | "failed" | "timed_out";
30
+ outputFile?: string;
31
+ error?: string;
32
+ retries: number;
33
+ }
34
+ export interface AgentResult {
35
+ outcome: "completed" | "failed" | "timed_out";
36
+ output?: string;
37
+ error?: string;
38
+ }
39
+ export interface AgentRunnerOptions {
40
+ cwd?: string;
41
+ env?: Record<string, string>;
42
+ }
43
+ export interface AgentRunner {
44
+ run(stageName: string, prompt: string, model: string, timeout: number, taskDir: string, options?: AgentRunnerOptions): Promise<AgentResult>;
45
+ healthCheck(): Promise<boolean>;
46
+ }
47
+ export interface PipelineContext {
48
+ taskId: string;
49
+ taskDir: string;
50
+ projectDir: string;
51
+ runners: Record<string, AgentRunner>;
52
+ input: {
53
+ mode: "full" | "rerun" | "status";
54
+ fromStage?: string;
55
+ dryRun?: boolean;
56
+ issueNumber?: number;
57
+ feedback?: string;
58
+ local?: boolean;
59
+ complexity?: "low" | "medium" | "high";
60
+ };
61
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ export interface ValidationResult {
2
+ valid: boolean;
3
+ error?: string;
4
+ }
5
+ export declare function stripFences(content: string): string;
6
+ export declare function validateTaskJson(content: string): ValidationResult;
7
+ export declare function validatePlanMd(content: string): ValidationResult;
8
+ export declare function validateReviewMd(content: string): ValidationResult;
@@ -0,0 +1,42 @@
1
+ const REQUIRED_TASK_FIELDS = [
2
+ "task_type",
3
+ "title",
4
+ "description",
5
+ "scope",
6
+ "risk_level",
7
+ ];
8
+ export function stripFences(content) {
9
+ return content.replace(/^```json\s*\n?/m, "").replace(/\n?```\s*$/m, "");
10
+ }
11
+ export function validateTaskJson(content) {
12
+ try {
13
+ const parsed = JSON.parse(stripFences(content));
14
+ for (const field of REQUIRED_TASK_FIELDS) {
15
+ if (!(field in parsed)) {
16
+ return { valid: false, error: `Missing field: ${field}` };
17
+ }
18
+ }
19
+ return { valid: true };
20
+ }
21
+ catch (err) {
22
+ return {
23
+ valid: false,
24
+ error: `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
25
+ };
26
+ }
27
+ }
28
+ export function validatePlanMd(content) {
29
+ if (content.length < 10) {
30
+ return { valid: false, error: "Plan is too short (< 10 chars)" };
31
+ }
32
+ if (!/^##\s+\w+/m.test(content)) {
33
+ return { valid: false, error: "Plan has no markdown h2 sections" };
34
+ }
35
+ return { valid: true };
36
+ }
37
+ export function validateReviewMd(content) {
38
+ if (/pass/i.test(content) || /fail/i.test(content)) {
39
+ return { valid: true };
40
+ }
41
+ return { valid: false, error: "Review must contain 'pass' or 'fail'" };
42
+ }
@@ -0,0 +1,11 @@
1
+ export interface VerifyResult {
2
+ pass: boolean;
3
+ errors: string[];
4
+ summary: string[];
5
+ }
6
+ /**
7
+ * Parse a command string into [executable, ...args], respecting quoted arguments.
8
+ * e.g., 'pnpm -s "test:unit"' → ["pnpm", "-s", "test:unit"]
9
+ */
10
+ export declare function parseCommand(cmd: string): string[];
11
+ export declare function runQualityGates(taskDir: string, projectRoot?: string): VerifyResult;
@@ -0,0 +1,110 @@
1
+ import { execFileSync } from "child_process";
2
+ import { getProjectConfig, VERIFY_COMMAND_TIMEOUT_MS } from "./config.js";
3
+ import { logger } from "./logger.js";
4
+ function isExecError(err) {
5
+ return typeof err === "object" && err !== null;
6
+ }
7
+ /**
8
+ * Parse a command string into [executable, ...args], respecting quoted arguments.
9
+ * e.g., 'pnpm -s "test:unit"' → ["pnpm", "-s", "test:unit"]
10
+ */
11
+ export function parseCommand(cmd) {
12
+ const parts = [];
13
+ let current = "";
14
+ let inQuote = null;
15
+ for (const ch of cmd) {
16
+ if (inQuote) {
17
+ if (ch === inQuote) {
18
+ inQuote = null;
19
+ }
20
+ else {
21
+ current += ch;
22
+ }
23
+ }
24
+ else if (ch === '"' || ch === "'") {
25
+ inQuote = ch;
26
+ }
27
+ else if (/\s/.test(ch)) {
28
+ if (current) {
29
+ parts.push(current);
30
+ current = "";
31
+ }
32
+ }
33
+ else {
34
+ current += ch;
35
+ }
36
+ }
37
+ if (current)
38
+ parts.push(current);
39
+ if (inQuote)
40
+ logger.warn(`Unclosed quote in command: ${cmd}`);
41
+ return parts;
42
+ }
43
+ function runCommand(cmd, cwd, timeout) {
44
+ const parts = parseCommand(cmd);
45
+ if (parts.length === 0) {
46
+ return { success: true, output: "", timedOut: false };
47
+ }
48
+ try {
49
+ const output = execFileSync(parts[0], parts.slice(1), {
50
+ cwd,
51
+ timeout,
52
+ encoding: "utf-8",
53
+ stdio: ["pipe", "pipe", "pipe"],
54
+ env: { ...process.env, FORCE_COLOR: "0" },
55
+ });
56
+ return { success: true, output: output ?? "", timedOut: false };
57
+ }
58
+ catch (err) {
59
+ const stdout = isExecError(err) ? err.stdout ?? "" : "";
60
+ const stderr = isExecError(err) ? err.stderr ?? "" : "";
61
+ const killed = isExecError(err) ? !!err.killed : false;
62
+ return { success: false, output: `${stdout}${stderr}`, timedOut: killed };
63
+ }
64
+ }
65
+ function parseErrors(output) {
66
+ const errors = [];
67
+ for (const line of output.split("\n")) {
68
+ if (/error|Error|ERROR|failed|Failed|FAIL|warning:|Warning:|WARN/i.test(line)) {
69
+ errors.push(line.slice(0, 500));
70
+ }
71
+ }
72
+ return errors;
73
+ }
74
+ function extractSummary(output, cmdName) {
75
+ const summaryPatterns = /Test Suites|Tests|Coverage|ERRORS|FAILURES|success|completed/i;
76
+ const lines = output.split("\n").filter((l) => summaryPatterns.test(l));
77
+ return lines.slice(-3).map((l) => `[${cmdName}] ${l.trim()}`);
78
+ }
79
+ export function runQualityGates(taskDir, projectRoot) {
80
+ const config = getProjectConfig();
81
+ const cwd = projectRoot ?? process.cwd();
82
+ const allErrors = [];
83
+ const allSummary = [];
84
+ let allPass = true;
85
+ const commands = [
86
+ { name: "typecheck", cmd: config.quality.typecheck },
87
+ { name: "test", cmd: config.quality.testUnit },
88
+ ];
89
+ if (config.quality.lint) {
90
+ commands.push({ name: "lint", cmd: config.quality.lint });
91
+ }
92
+ for (const { name, cmd } of commands) {
93
+ if (!cmd)
94
+ continue;
95
+ logger.info(` Running ${name}: ${cmd}`);
96
+ const result = runCommand(cmd, cwd, VERIFY_COMMAND_TIMEOUT_MS);
97
+ if (result.timedOut) {
98
+ allErrors.push(`${name}: timed out after ${VERIFY_COMMAND_TIMEOUT_MS / 1000}s`);
99
+ allPass = false;
100
+ continue;
101
+ }
102
+ if (!result.success) {
103
+ allPass = false;
104
+ const errors = parseErrors(result.output);
105
+ allErrors.push(...errors.map((e) => `[${name}] ${e}`));
106
+ }
107
+ allSummary.push(...extractSummary(result.output, name));
108
+ }
109
+ return { pass: allPass, errors: allErrors, summary: allSummary };
110
+ }