pi-harness-runtime 0.2.0 → 0.3.1

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.
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Master Planner — RFC-0017
3
+ *
4
+ * Converts a human requirement into an executable task graph.
5
+ * Uses a planning LLM to decompose the requirement into tasks with dependencies.
6
+ */
7
+
8
+ import type { TaskGraph } from "../packages/types/src/runtime-types.ts";
9
+ import { TaskGraphManager } from "./task-graph.ts";
10
+
11
+ export interface PlanResult {
12
+ success: boolean;
13
+ graph?: TaskGraph;
14
+ error?: string;
15
+ }
16
+
17
+ export interface PlannerOptions {
18
+ planningProvider?: {
19
+ call: (prompt: string, systemPrompt: string) => Promise<string>;
20
+ };
21
+ maxTasks?: number;
22
+ }
23
+
24
+ const DEFAULT_SYSTEM_PROMPT = `You are a software project planner. Given a human requirement, decompose it into a clear task list.
25
+
26
+ Rules:
27
+ 1. Each task should be atomic and independently testable
28
+ 2. Tasks must be ordered with proper dependencies
29
+ 3. Include acceptance criteria for each task
30
+ 4. Consider: analysis, implementation, testing, review phases
31
+ 5. Output ONLY valid JSON in the specified format
32
+
33
+ Output format:
34
+ {
35
+ "tasks": [
36
+ {
37
+ "id": "task-001",
38
+ "title": "Descriptive title",
39
+ "description": "What this task does",
40
+ "dependencies": [], // array of task IDs this depends on
41
+ "acceptanceCriteria": ["criterion 1", "criterion 2"]
42
+ }
43
+ ]
44
+ }`;
45
+
46
+ export class MasterPlanner {
47
+ constructor(private readonly options: PlannerOptions = {}) {}
48
+
49
+ /**
50
+ * Create a plan from a requirement
51
+ */
52
+ async createPlan(
53
+ requirement: string,
54
+ jobId: string,
55
+ rootDir: string,
56
+ ): Promise<PlanResult> {
57
+ try {
58
+ // Build the planning prompt
59
+ const userPrompt = `Human requirement:\n${requirement}\n\nMax tasks: ${this.options.maxTasks ?? 20}`;
60
+
61
+ let taskList: {
62
+ id: string;
63
+ title: string;
64
+ description: string;
65
+ dependencies: string[];
66
+ acceptanceCriteria?: string[];
67
+ }[];
68
+
69
+ if (this.options.planningProvider) {
70
+ // Use LLM to generate task list
71
+ const response = await this.options.planningProvider.call(
72
+ userPrompt,
73
+ DEFAULT_SYSTEM_PROMPT,
74
+ );
75
+ const parsed = this.parsePlanningResponse(response);
76
+ if (!parsed) {
77
+ return { success: false, error: "Failed to parse planning response" };
78
+ }
79
+ taskList = parsed;
80
+ } else {
81
+ // Use heuristic planner for simple requirements
82
+ taskList = this.heuristicPlan(requirement);
83
+ }
84
+
85
+ // Validate the task list
86
+ if (taskList.length === 0) {
87
+ return { success: false, error: "No tasks generated from requirement" };
88
+ }
89
+
90
+ // Validate dependencies (no cycles, all deps exist)
91
+ const taskIds = new Set(taskList.map((t) => t.id));
92
+ for (const task of taskList) {
93
+ for (const dep of task.dependencies) {
94
+ if (!taskIds.has(dep)) {
95
+ return {
96
+ success: false,
97
+ error: `Task ${task.id} has invalid dependency: ${dep}`,
98
+ };
99
+ }
100
+ }
101
+ }
102
+
103
+ // Create task graph
104
+ const graphManager = new TaskGraphManager({ jobId });
105
+
106
+ for (const task of taskList) {
107
+ graphManager.addTask(
108
+ task.id,
109
+ task.title,
110
+ task.description,
111
+ task.dependencies,
112
+ task.acceptanceCriteria,
113
+ );
114
+ }
115
+
116
+ const graph = graphManager.getGraph();
117
+
118
+ // Save the graph
119
+ await graphManager.save(rootDir);
120
+
121
+ return { success: true, graph };
122
+ } catch (error) {
123
+ return { success: false, error: String(error) };
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Parse planning response from LLM
129
+ */
130
+ private parsePlanningResponse(
131
+ response: string,
132
+ ):
133
+ | {
134
+ id: string;
135
+ title: string;
136
+ description: string;
137
+ dependencies: string[];
138
+ acceptanceCriteria?: string[];
139
+ }[]
140
+ | null {
141
+ // Try to extract JSON from response
142
+ const jsonMatch =
143
+ response.match(/```json\n([\s\S]*?)\n```/) ??
144
+ response.match(/\{[\s\S]*"tasks"[\s\S]*\}/);
145
+
146
+ if (!jsonMatch) {
147
+ console.error("Failed to extract JSON from planning response");
148
+ return null;
149
+ }
150
+
151
+ const jsonStr = jsonMatch[1] ?? jsonMatch[0];
152
+
153
+ try {
154
+ const parsed = JSON.parse(jsonStr);
155
+ return parsed.tasks ?? [];
156
+ } catch {
157
+ console.error("Failed to parse JSON from planning response");
158
+ return null;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Heuristic planner for simple requirements
164
+ * Used when no LLM provider is available
165
+ */
166
+ heuristicPlan(
167
+ requirement: string,
168
+ ): {
169
+ id: string;
170
+ title: string;
171
+ description: string;
172
+ dependencies: string[];
173
+ acceptanceCriteria?: string[];
174
+ }[] {
175
+ const tasks: {
176
+ id: string;
177
+ title: string;
178
+ description: string;
179
+ dependencies: string[];
180
+ acceptanceCriteria?: string[];
181
+ }[] = [];
182
+ const req = requirement.toLowerCase();
183
+
184
+ // Task 1: Analysis
185
+ tasks.push({
186
+ id: "task-001",
187
+ title: "Analyze requirements",
188
+ description: `Analyze and document the requirements: ${requirement}`,
189
+ dependencies: [],
190
+ acceptanceCriteria: [
191
+ "Requirements are clearly documented",
192
+ "All edge cases identified",
193
+ "Technical approach defined",
194
+ ],
195
+ });
196
+
197
+ // Task 2: Implementation
198
+ if (
199
+ req.includes("api") ||
200
+ req.includes("endpoint") ||
201
+ req.includes("backend")
202
+ ) {
203
+ tasks.push({
204
+ id: "task-002",
205
+ title: "Implement API endpoints",
206
+ description: "Create API endpoints based on requirements",
207
+ dependencies: ["task-001"],
208
+ acceptanceCriteria: [
209
+ "Endpoints return correct responses",
210
+ "Error handling implemented",
211
+ "Input validation in place",
212
+ ],
213
+ });
214
+ }
215
+
216
+ if (
217
+ req.includes("database") ||
218
+ req.includes("model") ||
219
+ req.includes("schema")
220
+ ) {
221
+ tasks.push({
222
+ id: "task-003",
223
+ title: "Implement database schema",
224
+ description: "Create database models and migrations",
225
+ dependencies: ["task-001"],
226
+ acceptanceCriteria: [
227
+ "Schema matches requirements",
228
+ "Migrations run successfully",
229
+ "Relationships defined correctly",
230
+ ],
231
+ });
232
+ }
233
+
234
+ if (
235
+ req.includes("ui") ||
236
+ req.includes("frontend") ||
237
+ req.includes("page") ||
238
+ req.includes("component")
239
+ ) {
240
+ tasks.push({
241
+ id: "task-004",
242
+ title: "Implement UI components",
243
+ description: "Create frontend UI components",
244
+ dependencies: ["task-001"],
245
+ acceptanceCriteria: [
246
+ "Components match design",
247
+ "Responsive on all devices",
248
+ "Accessible",
249
+ ],
250
+ });
251
+ }
252
+
253
+ // Task 5: Tests
254
+ const implDeps = tasks
255
+ .filter((t) => t.id.startsWith("task-00"))
256
+ .map((t) => t.id);
257
+ tasks.push({
258
+ id: "task-010",
259
+ title: "Write unit tests",
260
+ description: "Write unit tests for all implemented code",
261
+ dependencies: implDeps.length > 0 ? implDeps : ["task-001"],
262
+ acceptanceCriteria: [
263
+ "All new code has >80% test coverage",
264
+ "All tests pass",
265
+ "Edge cases covered",
266
+ ],
267
+ });
268
+
269
+ // Task 6: Integration
270
+ tasks.push({
271
+ id: "task-011",
272
+ title: "Integration testing",
273
+ description: "Run integration tests and verify end-to-end flow",
274
+ dependencies: ["task-010"],
275
+ acceptanceCriteria: [
276
+ "Integration tests pass",
277
+ "No regression in existing functionality",
278
+ ],
279
+ });
280
+
281
+ // Task 7: Review
282
+ tasks.push({
283
+ id: "task-012",
284
+ title: "Code review",
285
+ description: "Review code for quality, security, and best practices",
286
+ dependencies: ["task-011"],
287
+ acceptanceCriteria: [
288
+ "Code follows project style guide",
289
+ "No security vulnerabilities",
290
+ "Documentation updated",
291
+ ],
292
+ });
293
+
294
+ return tasks;
295
+ }
296
+
297
+ /**
298
+ * Generate a simple task ID
299
+ */
300
+ static generateTaskId(index: number): string {
301
+ return `task-${String(index).padStart(3, "0")}`;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Parse a requirement into a basic task list (synchronous, no LLM)
307
+ */
308
+ export function parseRequirementIntoTasks(
309
+ requirement: string,
310
+ jobId: string,
311
+ ): TaskGraphManager {
312
+ const planner = new MasterPlanner();
313
+ const taskList = planner.heuristicPlan(requirement);
314
+
315
+ const graphManager = new TaskGraphManager({ jobId });
316
+ for (const task of taskList) {
317
+ graphManager.addTask(
318
+ task.id,
319
+ task.title,
320
+ task.description,
321
+ task.dependencies,
322
+ task.acceptanceCriteria,
323
+ );
324
+ }
325
+
326
+ return graphManager;
327
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Project Detector — RFC-0014
3
+ *
4
+ * Auto-detect project framework and choose suitable dummy data,
5
+ * seed strategy, and E2E approach.
6
+ */
7
+
8
+ import type {
9
+ ProjectType,
10
+ ProjectDetection,
11
+ SeedStrategy,
12
+ E2EStrategy,
13
+ } from "../../packages/types/src/runtime-types.ts";
14
+ import { readJson } from "../../cli.ts";
15
+ // @ts-expect-error - Bun has built-in Node.js types
16
+ import { join } from "node:path";
17
+
18
+ export interface DetectionSignal {
19
+ pattern: string;
20
+ weight: number;
21
+ matched: boolean;
22
+ }
23
+
24
+ export interface ProjectDetectorOptions {
25
+ rootDir?: string;
26
+ maxDepth?: number;
27
+ }
28
+
29
+ export class ProjectDetector {
30
+ private readonly signals: Map<ProjectType, DetectionSignal[]> = new Map([
31
+ [
32
+ "frappe_erpnext",
33
+ [
34
+ { pattern: "frappe-bench/sites", weight: 0.3, matched: false },
35
+ { pattern: "frappe-bench/apps", weight: 0.2, matched: false },
36
+ { pattern: "hooks.py", weight: 0.2, matched: false },
37
+ { pattern: "doctype/", weight: 0.2, matched: false },
38
+ { pattern: "bench", weight: 0.1, matched: false },
39
+ ],
40
+ ],
41
+ [
42
+ "frappe_spa",
43
+ [
44
+ {
45
+ pattern: "frappe-bench/apps/*/frontend",
46
+ weight: 0.3,
47
+ matched: false,
48
+ },
49
+ { pattern: "vite.config.", weight: 0.2, matched: false },
50
+ { pattern: ".reactrc", weight: 0.15, matched: false },
51
+ { pattern: "package.json", weight: 0.15, matched: false },
52
+ { pattern: "tsx", weight: 0.2, matched: false },
53
+ ],
54
+ ],
55
+ [
56
+ "nextjs",
57
+ [
58
+ { pattern: "next.config.", weight: 0.3, matched: false },
59
+ { pattern: "package.json", weight: 0.15, matched: false },
60
+ { pattern: "pages/", weight: 0.2, matched: false },
61
+ { pattern: "app/", weight: 0.25, matched: false },
62
+ { pattern: '"next"', weight: 0.1, matched: false },
63
+ ],
64
+ ],
65
+ [
66
+ "react_vite",
67
+ [
68
+ { pattern: "vite.config.", weight: 0.3, matched: false },
69
+ { pattern: "package.json", weight: 0.15, matched: false },
70
+ { pattern: "src/main.tsx", weight: 0.25, matched: false },
71
+ { pattern: "index.html", weight: 0.15, matched: false },
72
+ { pattern: '"vite"', weight: 0.15, matched: false },
73
+ ],
74
+ ],
75
+ [
76
+ "django",
77
+ [
78
+ { pattern: "manage.py", weight: 0.4, matched: false },
79
+ { pattern: "settings.py", weight: 0.25, matched: false },
80
+ { pattern: "wsgi.py", weight: 0.15, matched: false },
81
+ { pattern: "migrations/", weight: 0.2, matched: false },
82
+ ],
83
+ ],
84
+ [
85
+ "laravel",
86
+ [
87
+ { pattern: "artisan", weight: 0.4, matched: false },
88
+ { pattern: "database/seeders", weight: 0.25, matched: false },
89
+ { pattern: "composer.json", weight: 0.15, matched: false },
90
+ { pattern: "routes/", weight: 0.2, matched: false },
91
+ ],
92
+ ],
93
+ ]);
94
+
95
+ /**
96
+ * Detect project type from a directory
97
+ */
98
+ async detect(rootDir: string): Promise<ProjectDetection> {
99
+ const projectFiles = await this.scanDirectory(rootDir);
100
+
101
+ // Score each project type
102
+ const scores: Record<ProjectType, number> = {
103
+ frappe_erpnext: 0,
104
+ frappe_spa: 0,
105
+ nextjs: 0,
106
+ react_vite: 0,
107
+ django: 0,
108
+ laravel: 0,
109
+ generic_web: 0,
110
+ unknown: 0,
111
+ };
112
+
113
+ const signals: string[] = [];
114
+
115
+ for (const [projectType, signalList] of this.signals) {
116
+ for (const signal of signalList) {
117
+ signal.matched = false;
118
+ for (const file of projectFiles) {
119
+ if (file.includes(signal.pattern)) {
120
+ signal.matched = true;
121
+ scores[projectType] += signal.weight;
122
+ if (!signals.includes(signal.pattern)) {
123
+ signals.push(signal.pattern);
124
+ }
125
+ break;
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ // Find the project type with highest score
132
+ let bestType: ProjectType = "unknown";
133
+ let bestScore = 0;
134
+
135
+ for (const [projectType, score] of Object.entries(scores)) {
136
+ if (score > bestScore) {
137
+ bestScore = score;
138
+ bestType = projectType as ProjectType;
139
+ }
140
+ }
141
+
142
+ // Check for generic web
143
+ if (projectFiles.some((f) => f === "package.json")) {
144
+ if (bestType === "unknown") {
145
+ bestType = "generic_web";
146
+ }
147
+ }
148
+
149
+ // Determine confidence based on score
150
+ const confidence = Math.min(1, bestScore);
151
+
152
+ return {
153
+ projectType: bestType,
154
+ confidence,
155
+ signals,
156
+ recommendedSeedStrategy: this.getSeedStrategy(bestType),
157
+ recommendedE2EStrategy: this.getE2EStrategy(bestType),
158
+ ...this.getAdditionalInfo(bestType, projectFiles),
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Scan directory for relevant files
164
+ */
165
+ private async scanDirectory(rootDir: string): Promise<string[]> {
166
+ // This is a simplified implementation.
167
+ // In practice, you'd use recursive directory scanning.
168
+ const files: string[] = [];
169
+
170
+ try {
171
+ // Check for common files
172
+ const checks = [
173
+ "frappe-bench",
174
+ "frappe-bench/sites",
175
+ "frappe-bench/apps",
176
+ "hooks.py",
177
+ "doctype",
178
+ "bench",
179
+ "vite.config.ts",
180
+ "vite.config.js",
181
+ "next.config.js",
182
+ "next.config.ts",
183
+ "package.json",
184
+ "pages",
185
+ "app",
186
+ "src/main.tsx",
187
+ "index.html",
188
+ "manage.py",
189
+ "settings.py",
190
+ "artisan",
191
+ "database/seeders",
192
+ ];
193
+
194
+ // @ts-expect-error - Bun has built-in file system access
195
+ const { existsSync } = await import("node:fs");
196
+ // @ts-expect-error - Bun has built-in file system access
197
+ const { readdirSync } = await import("node:fs");
198
+
199
+ for (const check of checks) {
200
+ const fullPath = join(rootDir, check);
201
+ if (existsSync(fullPath)) {
202
+ files.push(check);
203
+ }
204
+ }
205
+
206
+ // Scan root directory for common files
207
+ try {
208
+ const rootFiles = readdirSync(rootDir);
209
+ for (const file of rootFiles) {
210
+ if (file.includes("package.json")) files.push(file);
211
+ if (file.includes("vite.config")) files.push(file);
212
+ if (file.includes("next.config")) files.push(file);
213
+ }
214
+ } catch {
215
+ // Ignore errors
216
+ }
217
+ } catch {
218
+ // Return empty on error
219
+ }
220
+
221
+ return files;
222
+ }
223
+
224
+ /**
225
+ * Get recommended seed strategy
226
+ */
227
+ private getSeedStrategy(projectType: ProjectType): SeedStrategy {
228
+ const strategies: Record<ProjectType, SeedStrategy> = {
229
+ frappe_erpnext: "frappe_doc_insert",
230
+ frappe_spa: "frappe_site_seed",
231
+ nextjs: "nextjs_factory",
232
+ react_vite: "react_factory",
233
+ django: "django_fixture",
234
+ laravel: "laravel_factory",
235
+ generic_web: "generic_sql",
236
+ unknown: "generic_sql",
237
+ };
238
+ return strategies[projectType];
239
+ }
240
+
241
+ /**
242
+ * Get recommended E2E strategy
243
+ */
244
+ private getE2EStrategy(projectType: ProjectType): E2EStrategy {
245
+ const strategies: Record<ProjectType, E2EStrategy> = {
246
+ frappe_erpnext: "bench_site_browser_flow",
247
+ frappe_spa: "bench_site_browser_flow",
248
+ nextjs: "next_dev_server_flow",
249
+ react_vite: "vite_dev_server_flow",
250
+ django: "django_test_client_flow",
251
+ laravel: "laravel_dusk_flow",
252
+ generic_web: "generic_playwright_flow",
253
+ unknown: "generic_playwright_flow",
254
+ };
255
+ return strategies[projectType];
256
+ }
257
+
258
+ /**
259
+ * Get additional project info
260
+ */
261
+ private getAdditionalInfo(
262
+ projectType: ProjectType,
263
+ files: string[],
264
+ ): { framework?: string; version?: string } {
265
+ const info: { framework?: string; version?: string } = {};
266
+
267
+ // Try to extract version from package.json (if rootDir is available)
268
+ if (files.includes("package.json")) {
269
+ try {
270
+ const pkgPath = join(".", "package.json");
271
+ const pkg = readJson(pkgPath) as {
272
+ version?: string;
273
+ dependencies?: Record<string, string>;
274
+ } | null;
275
+ if (pkg?.version) {
276
+ info.version = pkg.version;
277
+ }
278
+ } catch {
279
+ // Ignore
280
+ }
281
+ }
282
+
283
+ // Set framework name
284
+ const frameworks: Partial<Record<ProjectType, string>> = {
285
+ frappe_erpnext: "Frappe/ERPNext",
286
+ frappe_spa: "Frappe SPA",
287
+ nextjs: "Next.js",
288
+ react_vite: "React + Vite",
289
+ django: "Django",
290
+ laravel: "Laravel",
291
+ generic_web: "Generic Web",
292
+ };
293
+ info.framework = frameworks[projectType];
294
+
295
+ return info;
296
+ }
297
+
298
+ /**
299
+ * Generate detection report
300
+ */
301
+ generateReport(detection: ProjectDetection): string {
302
+ const lines = [
303
+ "Project Detection Report",
304
+ "=".repeat(40),
305
+ "",
306
+ `Type: ${detection.projectType}`,
307
+ `Framework: ${detection.framework ?? "Unknown"}`,
308
+ `Confidence: ${(detection.confidence * 100).toFixed(1)}%`,
309
+ "",
310
+ "Signals:",
311
+ ];
312
+
313
+ for (const signal of detection.signals) {
314
+ lines.push(` - ${signal}`);
315
+ }
316
+
317
+ lines.push("");
318
+ lines.push("Recommendations:");
319
+ lines.push(` Seed Strategy: ${detection.recommendedSeedStrategy}`);
320
+ lines.push(` E2E Strategy: ${detection.recommendedE2EStrategy}`);
321
+
322
+ if (detection.version) {
323
+ lines.push(` Version: ${detection.version}`);
324
+ }
325
+
326
+ return lines.join("\n");
327
+ }
328
+ }