@zibby/core 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 +147 -0
  3. package/package.json +94 -0
  4. package/src/agents/base.js +361 -0
  5. package/src/constants.js +47 -0
  6. package/src/enrichment/base.js +49 -0
  7. package/src/enrichment/enrichers/accessibility-enricher.js +197 -0
  8. package/src/enrichment/enrichers/dom-enricher.js +171 -0
  9. package/src/enrichment/enrichers/page-state-enricher.js +129 -0
  10. package/src/enrichment/enrichers/position-enricher.js +67 -0
  11. package/src/enrichment/index.js +96 -0
  12. package/src/enrichment/mcp-integration.js +149 -0
  13. package/src/enrichment/mcp-ref-enricher.js +78 -0
  14. package/src/enrichment/pipeline.js +192 -0
  15. package/src/enrichment/trace-text-enricher.js +115 -0
  16. package/src/framework/AGENTS.md +98 -0
  17. package/src/framework/agents/base.js +72 -0
  18. package/src/framework/agents/claude-strategy.js +278 -0
  19. package/src/framework/agents/cursor-strategy.js +459 -0
  20. package/src/framework/agents/index.js +105 -0
  21. package/src/framework/agents/utils/cursor-output-formatter.js +67 -0
  22. package/src/framework/agents/utils/openai-proxy-formatter.js +249 -0
  23. package/src/framework/code-generator.js +301 -0
  24. package/src/framework/constants.js +33 -0
  25. package/src/framework/context-loader.js +101 -0
  26. package/src/framework/function-bridge.js +78 -0
  27. package/src/framework/function-skill-registry.js +20 -0
  28. package/src/framework/graph-compiler.js +342 -0
  29. package/src/framework/graph.js +610 -0
  30. package/src/framework/index.js +28 -0
  31. package/src/framework/node-registry.js +163 -0
  32. package/src/framework/node.js +259 -0
  33. package/src/framework/output-parser.js +71 -0
  34. package/src/framework/skill-registry.js +55 -0
  35. package/src/framework/state-utils.js +52 -0
  36. package/src/framework/state.js +67 -0
  37. package/src/framework/tool-resolver.js +65 -0
  38. package/src/index.js +342 -0
  39. package/src/runtime/generation/base.js +46 -0
  40. package/src/runtime/generation/index.js +70 -0
  41. package/src/runtime/generation/mcp-ref-strategy.js +197 -0
  42. package/src/runtime/generation/stable-id-strategy.js +170 -0
  43. package/src/runtime/stable-id-runtime.js +248 -0
  44. package/src/runtime/verification/base.js +44 -0
  45. package/src/runtime/verification/index.js +67 -0
  46. package/src/runtime/verification/playwright-json-strategy.js +119 -0
  47. package/src/runtime/zibby-runtime.js +299 -0
  48. package/src/sync/index.js +2 -0
  49. package/src/sync/uploader.js +29 -0
  50. package/src/tools/run-playwright-test.js +158 -0
  51. package/src/utils/adf-converter.js +68 -0
  52. package/src/utils/ast-utils.js +37 -0
  53. package/src/utils/ci-setup.js +124 -0
  54. package/src/utils/cursor-utils.js +71 -0
  55. package/src/utils/logger.js +144 -0
  56. package/src/utils/mcp-config-writer.js +115 -0
  57. package/src/utils/node-schema-parser.js +522 -0
  58. package/src/utils/post-process-events.js +55 -0
  59. package/src/utils/result-handler.js +102 -0
  60. package/src/utils/ripple-effect.js +84 -0
  61. package/src/utils/selector-generator.js +239 -0
  62. package/src/utils/streaming-parser.js +387 -0
  63. package/src/utils/test-post-processor.js +211 -0
  64. package/src/utils/timeline.js +217 -0
  65. package/src/utils/trace-parser.js +325 -0
  66. package/src/utils/video-organizer.js +91 -0
  67. package/templates/browser-test-automation/README.md +114 -0
  68. package/templates/browser-test-automation/graph.js +54 -0
  69. package/templates/browser-test-automation/nodes/execute-live.js +250 -0
  70. package/templates/browser-test-automation/nodes/generate-script.js +77 -0
  71. package/templates/browser-test-automation/nodes/index.js +3 -0
  72. package/templates/browser-test-automation/nodes/preflight.js +59 -0
  73. package/templates/browser-test-automation/nodes/utils.js +154 -0
  74. package/templates/browser-test-automation/result-handler.js +286 -0
  75. package/templates/code-analysis/graph.js +72 -0
  76. package/templates/code-analysis/index.js +18 -0
  77. package/templates/code-analysis/nodes/analyze-ticket-node.js +204 -0
  78. package/templates/code-analysis/nodes/create-pr-node.js +175 -0
  79. package/templates/code-analysis/nodes/finalize-node.js +118 -0
  80. package/templates/code-analysis/nodes/generate-code-node.js +425 -0
  81. package/templates/code-analysis/nodes/generate-test-cases-node.js +376 -0
  82. package/templates/code-analysis/nodes/services/prMetaService.js +86 -0
  83. package/templates/code-analysis/nodes/setup-node.js +142 -0
  84. package/templates/code-analysis/prompts/analyze-ticket.md +181 -0
  85. package/templates/code-analysis/prompts/generate-code.md +33 -0
  86. package/templates/code-analysis/prompts/generate-test-cases.md +110 -0
  87. package/templates/code-analysis/state.js +40 -0
  88. package/templates/code-implementation/graph.js +35 -0
  89. package/templates/code-implementation/index.js +7 -0
  90. package/templates/code-implementation/state.js +14 -0
  91. package/templates/global-setup.js +56 -0
  92. package/templates/index.js +94 -0
  93. package/templates/register-nodes.js +24 -0
@@ -0,0 +1,286 @@
1
+ /**
2
+ * BrowserTestResultHandler - Browser test automation post-processing.
3
+ * Extends core ResultHandler with event enrichment, video handling,
4
+ * and assertion → event ID resolution.
5
+ */
6
+
7
+ import { writeFileSync, existsSync, readFileSync, readdirSync, renameSync, statSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { spawnSync } from 'child_process';
10
+ import { ResultHandler, logger } from '@zibby/core';
11
+
12
+ export class BrowserTestResultHandler extends ResultHandler {
13
+
14
+ static async onNodeSaved(nodeFolder, executionData) {
15
+ this.enrichEvents(nodeFolder, executionData);
16
+ await this.renameVideoFile(nodeFolder);
17
+ }
18
+
19
+ // ── Video ──────────────────────────────────────────────────
20
+
21
+ static getVideoDurationMs(videoPath) {
22
+ try {
23
+ const { stdout: result } = spawnSync('ffprobe', [
24
+ '-v', 'error', '-show_entries', 'format=duration',
25
+ '-of', 'default=noprint_wrappers=1:nokey=1', videoPath
26
+ ], { encoding: 'utf-8', timeout: 5000 });
27
+ const durationSec = parseFloat(result.trim());
28
+ if (!isNaN(durationSec)) {
29
+ const durationMs = Math.round(durationSec * 1000);
30
+ logger.debug(`Video duration (ffprobe): ${durationMs}ms`);
31
+ return durationMs;
32
+ }
33
+ } catch (_e) { /* ffprobe not available */ }
34
+
35
+ try {
36
+ const stats = statSync(videoPath);
37
+ const durationMs = Math.floor(stats.mtimeMs - stats.birthtimeMs);
38
+ if (durationMs > 0 && durationMs < 600000) {
39
+ logger.debug(`Video duration (file stats): ${durationMs}ms`);
40
+ return durationMs;
41
+ }
42
+ } catch (e) {
43
+ console.warn(`āš ļø Could not determine video duration: ${e.message}`);
44
+ }
45
+
46
+ return null;
47
+ }
48
+
49
+ static recalculateEventTimestamps(folder, videoPath) {
50
+ try {
51
+ const eventsPath = join(folder, 'events.json');
52
+ if (!existsSync(eventsPath)) return;
53
+
54
+ const events = JSON.parse(readFileSync(eventsPath, 'utf-8'));
55
+ if (events.length === 0) return;
56
+
57
+ const videoDurationMs = this.getVideoDurationMs(videoPath);
58
+ if (!videoDurationMs) {
59
+ console.warn('āš ļø Could not get video duration');
60
+ return;
61
+ }
62
+
63
+ const closeEvent = events.find(e => e.type === 'close');
64
+ if (!closeEvent) {
65
+ console.warn('āš ļø No close event found');
66
+ return;
67
+ }
68
+
69
+ const closeMs = new Date(closeEvent.timestamp).getTime();
70
+ const videoStartMs = closeMs - videoDurationMs;
71
+ let realVideoStartMs = videoStartMs;
72
+
73
+ try {
74
+ const videoStats = statSync(videoPath);
75
+ const fileBirthMs = videoStats.birthtimeMs;
76
+
77
+ logger.info('Video file timing:');
78
+ logger.info(` File created: ${new Date(fileBirthMs).toISOString()}`);
79
+ logger.info(` Close event: ${new Date(closeMs).toISOString()}`);
80
+ logger.info(` Duration: ${videoDurationMs}ms`);
81
+ logger.info(` Calculated start: ${new Date(videoStartMs).toISOString()}`);
82
+
83
+ const birthToClose = closeMs - fileBirthMs;
84
+ if (birthToClose > 0 && birthToClose <= videoDurationMs + 5000) {
85
+ realVideoStartMs = fileBirthMs;
86
+ } else {
87
+ logger.debug('File birth unreliable, using calculated start');
88
+ }
89
+ } catch (e) {
90
+ logger.debug(`Using calculated video start: ${new Date(videoStartMs).toISOString()}`);
91
+ logger.debug(`Error: ${e.message}`);
92
+ }
93
+
94
+ events.forEach(event => {
95
+ const eventMs = new Date(event.timestamp).getTime();
96
+ const offsetMs = eventMs - realVideoStartMs;
97
+ event.videoOffsetMs = Math.max(0, Math.round(offsetMs));
98
+ event.videoOffsetFormatted = this._formatTime(event.videoOffsetMs);
99
+ });
100
+
101
+ writeFileSync(eventsPath, JSON.stringify(events, null, 2));
102
+ logger.info(`Recalculated ${events.length} events`);
103
+ } catch (err) {
104
+ console.warn(`[INFO] Could not recalculate timestamps: ${err.message}`);
105
+ }
106
+ }
107
+
108
+ static _formatTime(ms) {
109
+ const totalMs = Math.round(ms);
110
+ const totalSeconds = Math.floor(totalMs / 1000);
111
+ const hours = Math.floor(totalSeconds / 3600);
112
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
113
+ const seconds = totalSeconds % 60;
114
+ const milliseconds = totalMs % 1000;
115
+ return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(milliseconds).padStart(3, '0')}`;
116
+ }
117
+
118
+ static async renameVideoFile(folder) {
119
+ logger.debug(`renameVideoFile called for: ${folder}`);
120
+ await new Promise(resolve => setTimeout(resolve, 1000));
121
+
122
+ try {
123
+ const files = readdirSync(folder);
124
+ logger.debug(`Files in directory: ${files.join(', ')}`);
125
+ let videoFile = files.find(f => f.endsWith('.webm') && f !== 'recording.webm');
126
+
127
+ if (!videoFile) {
128
+ const parentDir = join(folder, '..');
129
+ const parentFiles = readdirSync(parentDir);
130
+ const parentVideo = parentFiles.find(f => f.endsWith('.webm') && f !== 'recording.webm');
131
+ if (parentVideo) {
132
+ const srcPath = join(parentDir, parentVideo);
133
+ const destPath = join(folder, parentVideo);
134
+ renameSync(srcPath, destPath);
135
+ logger.debug(`Moved video from session root: ${parentVideo}`);
136
+ videoFile = parentVideo;
137
+ }
138
+ }
139
+
140
+ if (videoFile) {
141
+ const oldPath = join(folder, videoFile);
142
+ const newPath = join(folder, 'recording.webm');
143
+ renameSync(oldPath, newPath);
144
+ logger.debug(`Renamed video: ${videoFile} → recording.webm`);
145
+ this.recalculateEventTimestamps(folder, newPath);
146
+ } else {
147
+ logger.debug(`No video file to rename (files: ${files.length})`);
148
+ }
149
+ } catch (err) {
150
+ console.warn(`āš ļø Error in renameVideoFile: ${err.message}`);
151
+ }
152
+ }
153
+
154
+ // ── Event enrichment + assertion resolution ────────────────
155
+
156
+ static enrichEvents(executeLiveFolder, executionData) {
157
+ try {
158
+ const eventsPath = join(executeLiveFolder, 'events.json');
159
+ if (!existsSync(eventsPath)) return;
160
+
161
+ let events = JSON.parse(readFileSync(eventsPath, 'utf-8'));
162
+ const aiActions = executionData.actions || [];
163
+ const aiSteps = executionData.steps || [];
164
+
165
+ logger.info(`Event Enrichment: ${events.length} recorded events, ${aiActions.length} AI actions`);
166
+ logger.info(`Recorded event types: ${events.map(e => e.type).join(', ')}`);
167
+ logger.info(`AI action types: ${aiActions.map(a => a.type).join(', ')}`);
168
+
169
+ const usedActionIndices = new Set();
170
+
171
+ events = events.map((event) => {
172
+ const actionableTypes = ['navigate', 'click', 'fill', 'type', 'select', 'keypress', 'hover', 'drag'];
173
+
174
+ if (actionableTypes.includes(event.type)) {
175
+ let bestMatch = null;
176
+ let bestScore = 0;
177
+
178
+ aiActions.forEach((action, actionIndex) => {
179
+ if (usedActionIndices.has(actionIndex)) return;
180
+
181
+ let score = 0;
182
+ const typeMatches =
183
+ action.type === event.type ||
184
+ (action.type === 'fill' && (event.type === 'fill' || event.type === 'type')) ||
185
+ (action.type === 'type' && (event.type === 'fill' || event.type === 'type'));
186
+
187
+ if (typeMatches) {
188
+ score += 10;
189
+ if (actionIndex === usedActionIndices.size) score += 5;
190
+
191
+ if (event.data?.params?.element && action.description) {
192
+ const elementStr = String(event.data.params.element);
193
+ if (action.description.toLowerCase().includes(elementStr.toLowerCase().slice(0, 10))) {
194
+ score += 3;
195
+ }
196
+ }
197
+
198
+ if (score > bestScore) {
199
+ bestScore = score;
200
+ bestMatch = { action, actionIndex };
201
+ }
202
+ }
203
+ });
204
+
205
+ if (bestMatch) {
206
+ event.description = bestMatch.action.description;
207
+ event.reasoning = bestMatch.action.reasoning;
208
+ event.matchedActionIndex = bestMatch.actionIndex;
209
+ usedActionIndices.add(bestMatch.actionIndex);
210
+
211
+ if (aiSteps.length > 0) {
212
+ const matchingStep = aiSteps.find(s => {
213
+ if (typeof s !== 'string') return false;
214
+ const sLower = s.toLowerCase();
215
+ return sLower.includes(event.type) ||
216
+ (event.data?.params?.element && sLower.includes(String(event.data.params.element).toLowerCase()));
217
+ });
218
+ if (matchingStep) event.step = matchingStep;
219
+ }
220
+ } else {
221
+ const toolName = event.data?.tool || 'action';
222
+ event.description = `${toolName} action`;
223
+ event.reasoning = 'Browser interaction';
224
+ }
225
+ } else if (event.type === 'screenshot') {
226
+ const evidenceScreenshot = executionData.evidenceScreenshot;
227
+ if (evidenceScreenshot) {
228
+ event.description = evidenceScreenshot.description;
229
+ event.verdict = evidenceScreenshot.verdict;
230
+ event.isEvidence = true;
231
+ event.reasoning = `Evidence of test ${evidenceScreenshot.verdict === 'pass' ? 'passing' : 'failing'}`;
232
+ logger.debug(`Enriched evidence screenshot: ${evidenceScreenshot.verdict.toUpperCase()}`);
233
+ }
234
+ }
235
+
236
+ return event;
237
+ });
238
+
239
+ logger.info(`Matching results: ${usedActionIndices.size}/${aiActions.length} AI actions used`);
240
+
241
+ writeFileSync(eventsPath, JSON.stringify(events, null, 2), 'utf-8');
242
+ logger.info(`Enriched ${events.length} events with AI descriptions and reasoning`);
243
+
244
+ // Resolve assertion verifiedAfterAction (action index) -> verifiedAtEventId (event id)
245
+ const assertions = executionData.assertions || [];
246
+ if (assertions.length > 0) {
247
+ const actionToEvent = new Map();
248
+ for (const event of events) {
249
+ if (event.matchedActionIndex !== undefined) {
250
+ actionToEvent.set(event.matchedActionIndex, event.id);
251
+ }
252
+ }
253
+
254
+ for (const assertion of assertions) {
255
+ if (assertion.verifiedAfterAction === undefined) continue;
256
+
257
+ const exactEvent = actionToEvent.get(assertion.verifiedAfterAction);
258
+ if (exactEvent !== undefined) {
259
+ assertion.verifiedAtEventId = exactEvent;
260
+ continue;
261
+ }
262
+
263
+ let bestActionIdx = -1;
264
+ for (const [actionIdx] of actionToEvent) {
265
+ if (actionIdx <= assertion.verifiedAfterAction && actionIdx > bestActionIdx) {
266
+ bestActionIdx = actionIdx;
267
+ }
268
+ }
269
+ if (bestActionIdx >= 0) {
270
+ assertion.verifiedAtEventId = actionToEvent.get(bestActionIdx);
271
+ }
272
+ }
273
+
274
+ const resultPath = join(executeLiveFolder, 'result.json');
275
+ if (existsSync(resultPath)) {
276
+ const resultData = JSON.parse(readFileSync(resultPath, 'utf-8'));
277
+ resultData.assertions = assertions;
278
+ writeFileSync(resultPath, JSON.stringify(resultData, null, 2), 'utf-8');
279
+ logger.info(`Resolved verifiedAtEventId for ${assertions.filter(a => a.verifiedAtEventId !== undefined).length}/${assertions.length} assertions`);
280
+ }
281
+ }
282
+ } catch (err) {
283
+ console.warn('āš ļø Could not enrich events:', err.message);
284
+ }
285
+ }
286
+ }
@@ -0,0 +1,72 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { setupNode } from './nodes/setup-node.js';
5
+ import { analyzeTicketNode } from './nodes/analyze-ticket-node.js';
6
+ import { generateCodeNode } from './nodes/generate-code-node.js';
7
+ import { generateTestCasesNode } from './nodes/generate-test-cases-node.js';
8
+ import { finalizeNode } from './nodes/finalize-node.js';
9
+ import { analysisStateSchema } from './state.js';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const promptsDir = join(__dirname, 'prompts');
13
+
14
+ // Helper to load prompt if file exists
15
+ function loadPrompt(filename) {
16
+ const path = join(promptsDir, filename);
17
+ if (existsSync(path)) {
18
+ return readFileSync(path, 'utf-8');
19
+ }
20
+ return null;
21
+ }
22
+
23
+ // Load prompt templates at graph definition time
24
+ const analyzeTicketPrompt = loadPrompt('analyze-ticket.md');
25
+ const generateCodePrompt = loadPrompt('generate-code.md');
26
+ const generateTestCasesPrompt = loadPrompt('generate-test-cases.md');
27
+
28
+ export function buildAnalysisGraph(graph) {
29
+ graph.setStateSchema(analysisStateSchema);
30
+
31
+ graph
32
+ .addNode('setup', setupNode)
33
+ .addNode('analyze_ticket', analyzeTicketNode, {
34
+ prompt: analyzeTicketPrompt
35
+ })
36
+ .addConditionalNode('validation_check', {
37
+ condition: (state) => {
38
+ const validation = state.analyze_ticket?.validation;
39
+ if (!validation?.canProceed) {
40
+ return 'finalize';
41
+ }
42
+ return 'generate_code';
43
+ }
44
+ })
45
+ .addNode('generate_code', generateCodeNode, {
46
+ prompt: generateCodePrompt
47
+ })
48
+ .addNode('generate_test_cases', generateTestCasesNode, {
49
+ prompt: generateTestCasesPrompt
50
+ })
51
+ .addNode('finalize', finalizeNode)
52
+ .setNodeType('validation_check', 'decision')
53
+ .setEntryPoint('setup')
54
+ .addEdge('setup', 'analyze_ticket')
55
+ .addEdge('analyze_ticket', 'validation_check')
56
+ .addConditionalEdges('validation_check', (state) => {
57
+ const validation = state.analyze_ticket?.validation || state.analyze_ticket_output?.validation;
58
+ if (validation?.canProceed) {
59
+ return 'generate_code';
60
+ }
61
+ return 'finalize';
62
+ }, {
63
+ labels: {
64
+ generate_code: 'if valid',
65
+ finalize: 'if invalid'
66
+ }
67
+ })
68
+ .addEdge('generate_code', 'generate_test_cases')
69
+ .addEdge('generate_test_cases', 'finalize');
70
+
71
+ return graph;
72
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Code Analysis Module
3
+ * Nodes for analyzing Jira tickets and generating code changes
4
+ */
5
+
6
+ // State Schema
7
+ export { analysisStateSchema } from './state.js';
8
+
9
+ // Nodes
10
+ export { setupNode } from './nodes/setup-node.js';
11
+ export { analyzeTicketNode } from './nodes/analyze-ticket-node.js';
12
+ export { generateCodeNode, implementCodeNode } from './nodes/generate-code-node.js';
13
+ export { generateTestCasesNode } from './nodes/generate-test-cases-node.js';
14
+ export { createPRNode } from './nodes/create-pr-node.js';
15
+ export { finalizeNode } from './nodes/finalize-node.js';
16
+
17
+ // Graph Builder
18
+ export { buildAnalysisGraph } from './graph.js';
@@ -0,0 +1,204 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { randomBytes } from 'crypto';
4
+ import { z } from 'zod';
5
+ import { adfToText } from '../../../src/utils/adf-converter.js';
6
+
7
+ const generateId = () => randomBytes(16).toString('hex');
8
+
9
+ const SuggestedChangeSchema = z.object({
10
+ field: z.enum(['summary', 'description', 'labels'])
11
+ .describe('Which ticket field this change applies to'),
12
+
13
+ original: z.string()
14
+ .describe('The original value of the field'),
15
+
16
+ suggested: z.string()
17
+ .describe('The suggested new value for the field'),
18
+
19
+ reasoning: z.string()
20
+ .describe('Explanation of why this change improves the ticket')
21
+ });
22
+
23
+ // Shared validation schema (used by both AI agent output and node output)
24
+ const ValidationSchema = z.object({
25
+ canProceed: z.boolean()
26
+ .describe('Whether the ticket has enough information to proceed with implementation'),
27
+
28
+ status: z.enum(['valid', 'insufficient_context', 'unclear_requirements', 'invalid_ticket', 'needs_clarification'])
29
+ .describe('Status indicating why we can or cannot proceed'),
30
+
31
+ reasoning: z.string()
32
+ .describe('Explanation of the validation decision - why the ticket is valid or what is missing'),
33
+
34
+ blockers: z.array(z.string())
35
+ .nullable()
36
+ .describe('Specific blockers preventing implementation (if canProceed is false), null if none')
37
+ });
38
+
39
+ const AnalysisOutputSchema = z.object({
40
+ validation: ValidationSchema
41
+ .describe('Validation of whether ticket is ready for implementation'),
42
+
43
+ suggestedChanges: z.array(SuggestedChangeSchema)
44
+ .describe('Array of suggested changes to ticket fields'),
45
+
46
+ technicalAnalysis: z.object({
47
+ requirements: z.array(z.string())
48
+ .describe('Key requirements extracted from the ticket'),
49
+
50
+ affectedFiles: z.array(z.string())
51
+ .nullable()
52
+ .describe('Files that will likely need changes (null if unknown). Each path MUST start with a top-level repo directory name (e.g. "my-api/src/...")'),
53
+
54
+ complexity: z.enum(['simple', 'medium', 'complex', 'unknown'])
55
+ .describe('Estimated complexity of the implementation'),
56
+
57
+ risks: z.array(z.string())
58
+ .nullable()
59
+ .describe('Potential risks or challenges, null if none')
60
+ }).describe('Technical analysis of the implementation'),
61
+
62
+ overallSummary: z.string()
63
+ .describe('Brief summary of the analysis findings'),
64
+
65
+ implementationPlan: z.string()
66
+ .describe('Markdown implementation plan for the code generation agent. Include: 1) A brief summary of what to build, 2) Which top-level repositories need changes — MUST be the exact directory names from Repository Information, NEVER internal sub-modules or Maven modules, 3) An ordered TODO checklist where every file path starts with a top-level repo name (e.g. "In my-api/src/.../Foo.java" NOT "src/.../Foo.java"), 4) Key technical details like function signatures, patterns to follow, and gotchas. This will be passed directly to the coding agent as its instructions.')
67
+ });
68
+
69
+ // Node output schema (what this node returns to state)
70
+ const AnalyzeTicketNodeOutputSchema = z.object({
71
+ success: z.boolean(),
72
+ analysis: z.object({
73
+ raw: z.string(),
74
+ structured: z.object({
75
+ validation: ValidationSchema,
76
+ suggestedChanges: z.array(z.any()),
77
+ technicalAnalysis: z.any(),
78
+ overallSummary: z.string(),
79
+ implementationPlan: z.string()
80
+ }),
81
+ timestamp: z.string()
82
+ }),
83
+ validation: ValidationSchema // Exposed at root for easy access by conditional logic
84
+ });
85
+
86
+ export const analyzeTicketNode = {
87
+ name: 'analyze_ticket',
88
+ outputSchema: AnalyzeTicketNodeOutputSchema,
89
+
90
+ execute: async (context) => {
91
+ console.log('\nšŸ” Analyzing ticket with Cursor Agent...');
92
+
93
+ // Extract from context (new unified signature)
94
+ const { state, invokeAgent } = context;
95
+ const { workspace, ticketContext, repos, model } = state.getAll();
96
+ const aiModel = model || ticketContext.model || 'auto';
97
+
98
+ // Prepare description text
99
+ let descriptionText = ticketContext.description || 'No description provided';
100
+ if (typeof descriptionText === 'object') {
101
+ descriptionText = adfToText(descriptionText);
102
+ console.log('āš ļø Description is in ADF format, converted to plain text');
103
+ }
104
+
105
+ // Build prompt values for template rendering
106
+ const promptValues = {
107
+ ticketKey: ticketContext.ticketKey,
108
+ ticketSummary: ticketContext.summary,
109
+ ticketDescription: descriptionText,
110
+ ticketType: ticketContext.type || 'Task',
111
+ ticketPriority: ticketContext.priority || 'Medium',
112
+ ticketLabels: Array.isArray(ticketContext.labels) ? ticketContext.labels.join(', ') : (ticketContext.labels || 'None'),
113
+ ticketComponents: Array.isArray(ticketContext.components)
114
+ ? ticketContext.components.map(c => c.name || c).join(', ') || 'None'
115
+ : (ticketContext.components || 'None'),
116
+ additionalContext: ticketContext.additionalContext || null,
117
+ repositories: Array.isArray(repos) ? repos.map(r => ({
118
+ name: r.name,
119
+ ...detectProjectInfo(join(workspace, r.name))
120
+ })) : []
121
+ };
122
+
123
+ const images = (ticketContext.imageAttachments || [])
124
+ .filter(att => att.base64)
125
+ .map(att => ({ mimeType: att.mimeType, base64: att.base64, filename: att.filename }));
126
+
127
+ if (images.length > 0) {
128
+ console.log(`šŸ–¼ļø Including ${images.length} image attachment(s) for vision analysis`);
129
+ }
130
+
131
+ console.log(`šŸš€ Running AI Agent to analyze ticket with model: ${aiModel}...`);
132
+
133
+ // Use context.invokeAgent - template rendering handled by framework
134
+ const validatedOutput = await invokeAgent(promptValues, {
135
+ model: aiModel,
136
+ schema: AnalysisOutputSchema,
137
+ images
138
+ });
139
+
140
+ // Handle both structured response and raw response scenarios
141
+ const output = validatedOutput?.structured || validatedOutput;
142
+
143
+ const suggestedChanges = (output?.suggestedChanges || []).map(change => ({
144
+ id: generateId(),
145
+ ...change,
146
+ status: 'pending'
147
+ }));
148
+
149
+ const analysis = {
150
+ raw: JSON.stringify(output),
151
+ structured: {
152
+ validation: output?.validation || { canProceed: false, status: 'invalid_ticket', reasoning: 'Failed to parse validation' },
153
+ suggestedChanges,
154
+ technicalAnalysis: output?.technicalAnalysis || { requirements: [], complexity: 'unknown' },
155
+ overallSummary: output?.overallSummary || 'Analysis failed',
156
+ implementationPlan: output?.implementationPlan || 'No plan generated'
157
+ },
158
+ timestamp: new Date().toISOString()
159
+ };
160
+
161
+ console.log(`āœ… Parsed ${suggestedChanges.length} suggested changes`);
162
+
163
+ if (!output?.validation?.canProceed) {
164
+ console.log(`āš ļø Ticket validation failed: ${output?.validation?.status || 'unknown'}`);
165
+ console.log(` Reasoning: ${output?.validation?.reasoning || 'No reasoning provided'}`);
166
+ if (output?.validation?.blockers) {
167
+ console.log(` Blockers:`);
168
+ output.validation.blockers.forEach(blocker => {
169
+ console.log(` - ${blocker}`);
170
+ });
171
+ }
172
+ }
173
+
174
+ return {
175
+ success: true,
176
+ analysis,
177
+ validation: output?.validation || { canProceed: false, status: 'invalid_ticket', reasoning: 'No validation data' }
178
+ };
179
+ }
180
+ };
181
+
182
+ function detectProjectInfo(repoPath) {
183
+ const info = {
184
+ framework: 'Unknown',
185
+ language: 'Unknown',
186
+ packageManager: 'Unknown'
187
+ };
188
+
189
+ if (existsSync(join(repoPath, 'package.json'))) {
190
+ info.language = 'JavaScript/TypeScript';
191
+ const pkg = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf-8'));
192
+
193
+ if (pkg.dependencies?.react) info.framework = 'React';
194
+ if (pkg.dependencies?.next) info.framework = 'Next.js';
195
+ if (pkg.dependencies?.vue) info.framework = 'Vue';
196
+ if (pkg.dependencies?.express) info.framework = 'Express';
197
+
198
+ if (existsSync(join(repoPath, 'yarn.lock'))) info.packageManager = 'yarn';
199
+ else if (existsSync(join(repoPath, 'pnpm-lock.yaml'))) info.packageManager = 'pnpm';
200
+ else info.packageManager = 'npm';
201
+ }
202
+
203
+ return info;
204
+ }