{
  "name": "eek-Go v3",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "coding-agent",
        "responseMode": "lastNode",
        "options": {}
      },
      "id": "wh-trigger",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        250,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const query = $json.query || {};\nconst body = $json.body || $json;\nconst message = query.message || body.message || body.goal || '';\nconst projectId = query.project_id || body.project_id || ('proj-' + Date.now());\nconst reference_url = query.reference_url || body.reference_url || null;\nconst image_data = body.image_data || null;\nreturn [{ json: { message, project_id: projectId, operation: 'init', goal: message, reference_url, image_data } }];"
      },
      "id": "extract-input",
      "name": "Extract Input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        450,
        300
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ ($env.FILE_API_URL || 'http://file-api:3456') + '/projects/' + $json.project_id + '/files-content' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($env.FILE_API_TOKEN || '') }}"
            }
          ]
        },
        "options": {}
      },
      "id": "fetch-project-files",
      "name": "Fetch Project Files",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        850,
        300
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// MODIFIED: Absorbs Research: Build Project Context\n// Caches _allFileContents, _researchDocs, _projectFileList in static data\n// so Phase 2 chunks don't need to re-fetch or re-compute.\nconst memory = $('Extract Input').first().json;\nconst message = $('Extract Input').first().json.message;\nconst allFiles = $json.files || [];\nconst staticData = $getWorkflowStaticData('global');\n\n// Cache all file contents for Phase 2\n// Reset execution-scoped static data from prior runs\nstaticData._buildAttempt = 0;\nstaticData._buildResult = {};\nstaticData._reviewResult = {};\nstaticData.p2Results = [];\nstaticData.allTasks = [];\nstaticData._pipelineReport = '';\n\n// Extract project memory (if it exists) \u2014 pipeline-managed, not a code file\nconst memoryFile = allFiles.find(f => f.path === 'memory.md');\nconst projectMemory = memoryFile ? memoryFile.content : '';\nstaticData._projectMemory = projectMemory;\n\n// Filter memory.md from file list so planner/coder can't modify it\nconst codeFiles = allFiles.filter(f => f.path !== 'memory.md');\nstaticData._allFileContents = codeFiles;\n\nconst fileList = codeFiles.map(f => f.path);\n\n// Build API surface (exports from each file)\nconst apiSurface = [];\nfor (const f of allFiles) {\n  if (!f.path.match(/\\.(ts|tsx|js|jsx)$/) || f.path.includes('node_modules')) continue;\n  const lines = (f.content || '').split('\\n');\n  const exports = [];\n  for (const line of lines) {\n    const trimmed = line.trim();\n    if (/^export\\s+default\\s/.test(trimmed)) {\n      exports.push(trimmed.replace(/\\{[\\s\\S]*$/, '{...}').substring(0, 120));\n    } else if (/^export\\s+(const|let|var|function|class|interface|type|enum)\\s/.test(trimmed)) {\n      exports.push(trimmed.replace(/\\{[\\s\\S]*$/, '{...}').replace(/=>[\\s\\S]*$/, '=> ...').substring(0, 150));\n    } else if (/^export\\s+\\{/.test(trimmed)) {\n      exports.push(trimmed.substring(0, 150));\n    }\n  }\n  if (exports.length > 0) {\n    apiSurface.push(`## ${f.path}\\n${exports.join('\\n')}`);\n  }\n}\n\n// Extract dependencies from package.json\nlet dependencies = {};\nlet devDependencies = {};\nlet depNames = [];\nfor (const f of allFiles) {\n  if (f.path === 'package.json' || f.path.endsWith('/package.json')) {\n    try {\n      const pkg = JSON.parse(f.content);\n      dependencies = { ...dependencies, ...(pkg.dependencies || {}) };\n      devDependencies = { ...devDependencies, ...(pkg.devDependencies || {}) };\n      depNames = [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})];\n    } catch(e) {}\n  }\n}\n\n// Build research docs string\nconst depList = Object.entries(dependencies).map(([k,v]) => `  ${k}: ${v}`).join('\\n');\nconst devDepList = Object.entries(devDependencies).map(([k,v]) => `  ${k}: ${v}`).join('\\n');\nlet researchDocs = '';\nif (depList || devDepList) {\n  researchDocs += '## Dependency Manifest\\nOnly use packages listed here.\\n';\n  if (depList) researchDocs += `dependencies:\\n${depList}\\n`;\n  if (devDepList) researchDocs += `devDependencies:\\n${devDepList}\\n`;\n}\nif (apiSurface.length > 0) {\n  researchDocs += '\\n## Project API Surface\\nMatch these import/export signatures exactly when importing from project files.\\n';\n  researchDocs += apiSurface.join('\\n\\n');\n}\n\n// Cache for all downstream phases\nstaticData._researchDocs = researchDocs;\nstaticData._projectFileList = fileList;\nstaticData._projectApiSummary = apiSurface.join('\\n');\nstaticData._projectDeps = depNames;\n\n// Cache planner input in static data so Build Request can read it\n// (Load Model HTTP response replaces $json, so downstream can't use $json)\nstaticData._plannerInput = {\n  message,\n  project_goal: memory.goal || message,\n  project_id: memory.project_id,\n  existing_files: fileList,\n  api_summary: apiSurface.join('\\n'),\n  installed_packages: depNames,\n  _scrapeData: memory._scrapeData || null,\n  image_data: memory.image_data || null,\n  project_memory: projectMemory\n};\n\nreturn [{ json: staticData._plannerInput }];"
      },
      "id": "prepare-planner",
      "name": "Prepare Planner Input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1050,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Read planner input from static data (Load Model HTTP response replaces $json)\nconst staticData = $getWorkflowStaticData('global');\nconst plannerInput = staticData._plannerInput || {};\nconst message = plannerInput.message || '';\nconst existingFiles = plannerInput.existing_files || [];\nconst apiSummary = plannerInput.api_summary || '';\nconst installedPkgs = plannerInput.installed_packages || [];\nconst scrapeData = plannerInput._scrapeData;\n\nlet projectContext = '';\nif (existingFiles.length > 0) {\n  projectContext += `\\n\\nEXISTING PROJECT FILES (only reference files from this list):\\n${existingFiles.join('\\n')}\\n`;\n}\nif (apiSummary) {\n  projectContext += `\\nAPI SURFACE (exports from each file):\\n${apiSummary}\\n`;\n}\nif (installedPkgs.length > 0) {\n  projectContext += `\\nINSTALLED PACKAGES (only reference packages from this list):\\n${installedPkgs.join(', ')}\\n`;\n}\n\n// Inject project memory for continuity across runs\nconst projectMemory = plannerInput.project_memory || '';\nif (projectMemory) {\n  projectContext += '\\n\\nPROJECT MEMORY (context from previous pipeline runs \u2014 use for continuity):\\n' + projectMemory.substring(0, 4000) + '\\n';\n}\n\nconst plannerPrompt = `You are a Senior Software Architect and UI/UX Design Expert. Your job is to break a coding request into MULTIPLE structured tasks with detailed visual specifications.\n\nDESIGN PRINCIPLES (apply to ALL task descriptions):\n- Main interaction element must be centered and dominant (40-60% of viewport height)\n- Use vertical single-column layouts for apps/games \u2014 never side-by-side grids unless explicitly requested\n- Content hierarchy: hero/main action at top \u2192 stats/feedback \u2192 secondary actions (shop, settings) at bottom\n- Mobile-first: everything fits in 100vh, no page-level scrollbars, internal scrolling only for lists\n- Dark backgrounds with bright accent colors. Use gradients for depth (purple-900 to black, not flat colors)\n- Every click/tap must produce visible feedback (scale animation, color flash, particle effect)\n- Numbers should animate when they change (bounce, pulse, scale). Use tabular-nums for counters\n- Cards: rounded-xl (12-16px), subtle shadows, semi-transparent backgrounds with backdrop-blur\n- Buttons: press animation (scale 0.95), hover glow, disabled state with reduced opacity\n- Color-code affordability: gold/amber = available, red/gray = locked, green = owned/success\n- Typography: big bold numbers (text-4xl+), clear hierarchy via weight not just size\n- Spacing: generous padding, never cramped. Mobile touch targets minimum 44px\n\nWHEN A REFERENCE IMAGE IS PROVIDED:\n- Describe the EXACT layout structure you see: column vs row, spacing ratios, component sizes\n- Specify exact colors, gradients, border styles, and shadows visible in the image\n- Note the visual hierarchy: what's biggest, what's brightest, what draws the eye first\n- Describe animations or interactive states implied by the design (hover effects, active states)\n- Include specific dimensions: \"toilet button should be 150px diameter\" not just \"large button\"\n- The coder CANNOT see the image \u2014 your description is their ONLY reference Each task will be handled by a separate coder with 128K context who can write up to 6 files at once.\\n\\nOutput a JSON object with ONE field:\\n\\n\"tasks\": array of task objects. IMPORTANT: You MUST create multiple tasks. Group files by concern (e.g. styles in one task, frontend components in another, config in another). Each task has:\\n- task_id: string like \"TASK-001\"\\n- description: detailed actionable description of what to implement, including specific requirements, color values, component names, API endpoints, and behavior. The coder cannot see the original request \u2014 your description is all they get.\\n- files: array of exact file paths to create or modify. List EVERY file this task needs \u2014 the coder can ONLY write files listed here. For existing projects, prefer modifying files from the EXISTING PROJECT FILES list. For new projects or missing functionality, CREATE new file paths as needed.\\n- dependencies: array of task_id strings this task depends on (empty if none)\\n- complexity: \"low\", \"medium\", or \"high\"\n- needs_concept: boolean \u2014 set to true if this task involves UI/visual work that has NO reference image from the user. Set to false if the user provided a reference image that covers this task's visual design, or if the task is purely logic/config with no visual component.\\n\\nRULES:\\n- Always split work across multiple tasks by concern: styles, components, routes, config, etc.\\n- Each task can touch up to 12-15 files. The coder has 128K context and currently uses less than 2% of it \u2014 give it MORE work per task. Aim for 2-3 LARGE tasks instead of many small ones. A single task can contain an entire feature (components + hooks + styles + config). Fewer tasks = faster pipeline\\n- The description must be SELF-CONTAINED \u2014 include ALL details the coder needs\\n- For existing projects, reference files from the EXISTING PROJECT FILES list when modifying. You MAY create new files that do not exist yet.\n- CRITICAL: For new projects (empty file list), you MUST include a setup task that creates: package.json (with ALL dependencies), index.html, vite.config.js (or next.config.js), tsconfig.json (if TypeScript), tailwind.config.js + postcss.config.js (if using Tailwind), and the main entry point (src/main.jsx or app/layout.tsx). The project MUST be buildable.\\n- Detect the project type from the files and packages. If the project is a React/Vite frontend (no Express or backend framework in INSTALLED PACKAGES), focus all tasks on frontend files. Only create server/middleware/API handler tasks if Express or a similar backend framework is in INSTALLED PACKAGES.\\n- In each task description, specify the EXACT import/export style each file should use (named vs default) based on the API SURFACE above.\\n- NEVER create a task whose only purpose is deleting files. The coder cannot delete files \u2014 it can only create or modify. If dead files exist, ignore them. They do not affect the build.\n- The FIRST task should always include project configuration files (package.json, config files, index.html) if they do not already exist. The project MUST compile and run after all tasks complete.\n- NEVER include memory.md in any task's file list \u2014 it is managed by the pipeline\n- Output ONLY the raw JSON object: {\"tasks\": [...]}\\n- No markdown, no explanation \u2014 ONLY the JSON with tasks`;\n\nlet messageContent;\nif (scrapeData && scrapeData.screenshot_b64) {\n  const cssTokensStr = JSON.stringify(scrapeData.css_tokens, null, 2);\n  const domStr = scrapeData.dom_summary || '';\n  messageContent = [\n    { type: 'image_url', image_url: { url: `data:image/png;base64,${scrapeData.screenshot_b64}` } },\n    { type: 'text', text: `${plannerPrompt}\\n\\nREFERENCE DESIGN (screenshot above):\\nCSS Tokens:\\n${cssTokensStr}\\n\\nDOM Structure:\\n${domStr}\\n\\nRequest: ${message}${projectContext}\\n\\n(Match the visual design, color palette, typography, and layout from the screenshot)` }\n  ];\n} else if (plannerInput.image_data) {\n  const userImage = plannerInput.image_data;\n  messageContent = [\n    { type: 'image_url', image_url: { url: 'data:image/png;base64,' + userImage } },\n    { type: 'text', text: plannerPrompt + '\\n\\nREFERENCE IMAGE (uploaded by user \u2014 replicate this design):\\n\\nRequest: ' + message + projectContext }\n  ];\n} else {\n  messageContent = `${plannerPrompt}\\n\\nRequest: ${message}${projectContext}`;\n}\n\nreturn [{\n  json: {\n    model: $env.PLANNER_MODEL || 'qwen3.5-27b@q4_k_m',\n    messages: [{ role: 'user', content: messageContent }],\n    temperature: 1.0,\n    top_p: 0.95,\n    top_k: 20,\n    min_p: 0.0,\n    presence_penalty: 0.0,\n    max_tokens: parseInt($env.PLANNER_MAX_TOKENS) || 32768,\n    chat_template_kwargs: { enable_thinking: true, max_thinking_tokens: 4096 }\n  }\n}];"
      },
      "id": "planner-build",
      "name": "Planner: Build Request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1250,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const raw = $json.choices[0].message.content || $json.choices[0].message.reasoning_content || '';\nlet content = raw.replace(/<think>[\\s\\S]*?<\\/think>/g, '').trim();\nif (content.includes('</think>')) content = content.split('</think>').pop().trim();\ncontent = content.replace(/<\\/?task>/g, '').trim();\ncontent = content.replace(/\\n+(?:Reasoning|Note|Explanation):[\\s\\S]*/i, '').trim();\ncontent = content.replace(/^```(?:json)?\\n?/, '').replace(/\\n?```$/, '').trim();\n\nlet tasks, planDocument = '';\ntry {\n  const parsed = JSON.parse(content);\n  if (Array.isArray(parsed)) {\n    tasks = parsed;\n  } else {\n    tasks = parsed.tasks || [parsed];\n    planDocument = parsed.plan_document || '';\n  }\n} catch (e) {\n  // Truncated JSON recovery: extract complete task objects\n  const tasksMatch = content.match(/\"tasks\"\\s*:\\s*\\[/);\n  if (tasksMatch) {\n    const arrStart = content.indexOf('[', tasksMatch.index);\n    let bracketDepth = 0;\n    let lastCompleteObj = -1;\n    for (let i = arrStart; i < content.length; i++) {\n      if (content[i] === '{') bracketDepth++;\n      if (content[i] === '}') {\n        bracketDepth--;\n        if (bracketDepth === 0) lastCompleteObj = i;\n      }\n    }\n    if (lastCompleteObj > arrStart) {\n      const recoveredArr = content.substring(arrStart, lastCompleteObj + 1) + ']';\n      try {\n        tasks = JSON.parse(recoveredArr);\n      } catch (e2) {\n        tasks = [{ task_id: 'TASK-FALLBACK-' + Date.now(), description: content, files: [], dependencies: [], complexity: 'high' }];\n      }\n    } else {\n      tasks = [{ task_id: 'TASK-FALLBACK-' + Date.now(), description: content, files: [], dependencies: [], complexity: 'high' }];\n    }\n  } else {\n    tasks = [{ task_id: 'TASK-FALLBACK-' + Date.now(), description: content, files: [], dependencies: [], complexity: 'high' }];\n  }\n}\n\ntasks = tasks.map(t => ({\n  ...t,\n  files: t.files || [],\n  dependencies: t.dependencies || [],\n  complexity: t.complexity || 'medium'\n}));\n\nreturn [{ json: { tasks, plan_document: planDocument } }];"
      },
      "id": "planner-parse",
      "name": "Planner: Parse Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1650,
        300
      ]
    },
    {
      "id": "research-fetch-docs",
      "name": "Research: Fetch Docs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2050,
        300
      ],
      "parameters": {
        "jsCode": "// Research: Fetch library docs \u2014 Context7 \u2192 Exa fallback \u2192 Magic UI\nconst http = require('http');\nconst https = require('https');\nconst staticData = $getWorkflowStaticData('global');\nconst allFiles = staticData._allFileContents || [];\nconst tasks = $json.tasks || [];\n\n// \u2500\u2500\u2500 1. Extract libraries from package.json + task text \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst libs = new Set();\nconst pkgFile = allFiles.find(f => f.path === 'package.json');\nif (pkgFile) {\n  try {\n    const pkg = JSON.parse(pkgFile.content);\n    for (const dep of Object.keys(pkg.dependencies || {})) libs.add(dep);\n    for (const dep of Object.keys(pkg.devDependencies || {})) libs.add(dep);\n  } catch(e) {}\n}\nconst taskText = tasks.map(t => t.description || '').join(' ').toLowerCase();\nconst knownLibs = ['react', 'next', 'nextjs', 'vite', 'tailwindcss', 'tailwind', 'express', 'prisma', 'framer-motion', 'heroui', 'zustand', 'zod', 'three', 'react-three', 'r3f', 'drei', '@react-three/fiber', '@react-three/drei'];\nfor (const lib of knownLibs) {\n  if (taskText.includes(lib)) {\n    const mapped = {'nextjs':'next','tailwind':'tailwindcss','react-three':'@react-three/fiber','r3f':'@react-three/fiber','drei':'@react-three/drei'};\n    libs.add(mapped[lib] || lib);\n  }\n}\nif (allFiles.some(f => f.path.match(/\\.(jsx|tsx)$/))) libs.add('react');\nconst skipList = new Set(['autoprefixer', 'postcss', 'typescript', 'vite', '@vitejs/plugin-react', '@types/react', '@types/node', '@types/react-dom', 'eslint', 'prettier', 'react', 'react-dom', 'tailwindcss', 'tailwind', '@fontsource/inter', '@fontsource/fira-code']);\nconst toFetch = [...libs].filter(l => !skipList.has(l)).slice(0, 7);\n\nif (toFetch.length === 0) {\n  staticData._researchDocs = '';\n  return [{ json: $json }];\n}\n\n// \u2500\u2500\u2500 2. MCP helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction mcpRequest(sessionId, method, params) {\n  return new Promise((resolve, reject) => {\n    const body = JSON.stringify({ jsonrpc: '2.0', method, params, id: Date.now() });\n    const req = http.request({\n      hostname: 'docky', port: 8811, path: '/mcp', method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        'Accept': 'application/json, text/event-stream',\n        'Content-Length': Buffer.byteLength(body),\n        ...(sessionId ? { 'Mcp-Session-Id': sessionId } : {})\n      }\n    }, res => {\n      let data = '';\n      res.on('data', c => data += c);\n      res.on('end', () => resolve({ data, sessionId: res.headers['mcp-session-id'] }));\n    });\n    req.on('error', reject);\n    req.setTimeout(20000, () => { req.destroy(); reject(new Error('timeout')); });\n    req.write(body); req.end();\n  });\n}\n\n// Exa MCP helper (HTTPS to mcp.exa.ai)\nfunction exaRequest(sessionId, method, params) {\n  return new Promise((resolve, reject) => {\n    const body = JSON.stringify({ jsonrpc: '2.0', method, params, id: Date.now() });\n    const headers = {\n      'Content-Type': 'application/json',\n      'Accept': 'application/json, text/event-stream',\n      'Content-Length': Buffer.byteLength(body),\n    };\n    if (sessionId) headers['Mcp-Session-Id'] = sessionId;\n    const req = https.request({\n      hostname: 'mcp.exa.ai', port: 443, path: '/mcp', method: 'POST', headers\n    }, res => {\n      let data = '';\n      if (!sessionId && res.headers['mcp-session-id']) {\n        sessionId = res.headers['mcp-session-id'];\n      }\n      res.on('data', c => data += c);\n      res.on('end', () => resolve({ data, sessionId }));\n    });\n    req.on('error', reject);\n    req.setTimeout(30000, () => { req.destroy(); reject(new Error('exa timeout')); });\n    req.write(body); req.end();\n  });\n}\n\nfunction parseSSE(raw) {\n  for (const line of raw.split('\\n')) {\n    if (line.startsWith('data: ') || line.startsWith('data:')) {\n      const payload = line.startsWith('data: ') ? line.slice(6) : line.slice(5);\n      try { return JSON.parse(payload); } catch(e) {}\n    }\n  }\n  try { return JSON.parse(raw); } catch(e) {}\n  return {};\n}\n\nconst docs = [];\nconst errors = [];\nconst docSources = {}; // track source per library\nlet docSource = 'none';\n\n// \u2500\u2500\u2500 3. Try Context7 first \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\ntry {\n  const init = await mcpRequest(null, 'initialize', {\n    protocolVersion: '2024-11-05', capabilities: {},\n    clientInfo: { name: 'eek-go-research', version: '1.0' }\n  });\n  const sid = init.sessionId;\n  if (!sid) throw new Error('No MCP session ID');\n\n  for (const lib of toFetch) {\n    try {\n      const resolve = await mcpRequest(sid, 'tools/call', {\n        name: 'resolve-library-id', arguments: { libraryName: lib }\n      });\n      const resolveData = parseSSE(resolve.data);\n      const resolveText = (resolveData.result?.content || []).map(c => c.text || '').join('');\n      if (resolveText.includes('Failed to retrieve')) { errors.push(lib + ': ctx7 failed'); continue; }\n      const idMatch = resolveText.match(/Context7-compatible library ID:\\s*(\\/[\\w.-]+\\/[\\w.-]+)/);\n      if (!idMatch) { errors.push(lib + ': no ID'); continue; }\n\n      const docsResp = await mcpRequest(sid, 'tools/call', {\n        name: 'get-library-docs',\n        arguments: { context7CompatibleLibraryID: idMatch[1], tokens: 5000, topic: 'setup components hooks API examples' }\n      });\n      const docsData = parseSSE(docsResp.data);\n      const docText = (docsData.result?.content || []).map(c => c.text || '').join('');\n      if (docText.length > 100 && !docText.includes('Failed to retrieve')) {\n        docs.push('## ' + lib + '\\n' + docText.substring(0, 8000));\n        docSources[lib] = 'Context7';\n        docSource = 'context7';\n      }\n    } catch(e) { errors.push(lib + ': ' + e.message); }\n  }\n} catch(e) {\n  errors.push('ctx7 init: ' + e.message);\n}\n\n\n// \u2500\u2500\u2500 3.5. GSAP MCP docs \u2014 if project uses GSAP \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nif (libs.has('gsap')) {\n  try {\n    const gsapInit = await mcpRequest(null, 'initialize', {\n      protocolVersion: '2024-11-05', capabilities: {},\n      clientInfo: { name: 'eek-go-gsap', version: '1.0' }\n    });\n    const gsapSid = gsapInit.sessionId;\n    if (gsapSid) {\n      // Get GSAP API docs for key APIs\n      const gsapApis = ['ScrollTrigger', 'gsap.to', 'gsap.from', 'gsap.timeline'];\n      const gsapDocs = [];\n      for (const api of gsapApis) {\n        try {\n          const gsapResp = await mcpRequest(gsapSid, 'tools/call', {\n            name: 'get_gsap_api_expert',\n            arguments: { api_element: api, level: 'intermediate' }\n          });\n          const gsapData = parseSSE(gsapResp.data);\n          const gsapText = (gsapData.result?.content || []).map(c => c.text || '').join('');\n          if (gsapText.length > 100 && !gsapText.includes('Error')) {\n            gsapDocs.push('### ' + api + '\\n' + gsapText.substring(0, 3000));\n          }\n        } catch(e) { errors.push('gsap-api-' + api + ': ' + e.message); }\n      }\n      if (gsapDocs.length > 0) {\n        docs.push('## GSAP API Reference (from GSAP Master MCP)\\n' + gsapDocs.join('\\n\\n'));\n        for (const api of gsapApis) docSources['gsap:' + api] = 'GSAP MCP';\n        docSource = docSource || 'gsap-mcp';\n      }\n    }\n  } catch(e) {\n    errors.push('gsap-mcp: ' + e.message);\n  }\n}\n\n// \u2500\u2500\u2500 4. Exa fallback \u2014 if Context7 returned 0 docs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nif (docs.length === 0) {\n  try {\n    const exaInit = await exaRequest(null, 'initialize', {\n      protocolVersion: '2024-11-05', capabilities: {},\n      clientInfo: { name: 'eek-go-research', version: '1.0' }\n    });\n    const exaSid = exaInit.sessionId;\n\n    // Build TARGETED queries from task descriptions, not generic library names\n    const mainLibs = toFetch.filter(l => !l.startsWith('@types'));\n    const queries = [];\n    \n    // Extract specific features/APIs mentioned in tasks\n    const allTaskText = tasks.map(t => (t.description || '')).join(' ');\n    \n    for (const lib of mainLibs.slice(0, 4)) {\n      // Find what the task actually needs from this library\n      const libLower = lib.toLowerCase();\n      const taskWords = allTaskText.split(/[\\s.,;:]+/).filter(w => w.length > 3);\n      \n      // Look for API names, component names, or feature keywords near the library mention\n      const relevantTerms = [];\n      const taskLower = allTaskText.toLowerCase();\n      const libIdx = taskLower.indexOf(libLower);\n      if (libIdx >= 0) {\n        // Get ~200 chars around the library mention for context\n        const nearby = allTaskText.substring(Math.max(0, libIdx - 100), Math.min(allTaskText.length, libIdx + 200));\n        // Extract capitalized words (likely API names) and quoted strings\n        const apiNames = nearby.match(/[A-Z][a-zA-Z]+(?:Router|Provider|Context|Hook|Plugin|Trigger|Effect|Animation)?/g) || [];\n        relevantTerms.push(...apiNames.slice(0, 3));\n      }\n      \n      // Build a specific query\n      const specifics = relevantTerms.length > 0 ? ' ' + relevantTerms.join(' ') : '';\n      queries.push(lib + specifics + ' usage examples API reference');\n    }\n    \n    // If no libs to fetch but tasks mention specific tech, search for that\n    if (queries.length === 0 && allTaskText.length > 50) {\n      const techMentions = allTaskText.match(/(?:GSAP|ScrollTrigger|framer.motion|react.router|BrowserRouter|createBrowserRouter|useEffect|useState)/gi) || [];\n      if (techMentions.length > 0) {\n        queries.push(techMentions.slice(0, 3).join(' ') + ' usage examples documentation');\n      }\n    }\n\n    for (const query of queries.slice(0, 3)) {\n      try {\n        const resp = await exaRequest(exaSid, 'tools/call', {\n          name: 'get_code_context_exa',\n          arguments: { query, num_results: 2 }\n        });\n        const respData = parseSSE(resp.data);\n        const text = (respData.result?.content || []).map(c => c.text || '').join('');\n        if (text.length > 200) {\n          // Trim to 6K chars per query to keep total context reasonable\n          docs.push('## Docs: ' + query.split(' API ')[0] + '\\n' + text.substring(0, 6000));\n          docSources[query.split(' ')[0]] = 'Exa';\n          docSource = 'exa';\n        }\n      } catch(e) { errors.push('exa: ' + e.message); }\n    }\n  } catch(e) {\n    errors.push('exa init: ' + e.message);\n  }\n}\n\n// \u2500\u2500\u2500 5. Design guide (always included) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst designGuide = `## UI/UX Design Principles for Web Applications\n\n### Layout\n- Main interaction element must be centered and visually dominant (40-60% of viewport)\n- Use vertical single-column layouts for game/app UIs \u2014 never side-by-side grids unless it's a dashboard\n- Content hierarchy: hero/main action \u2192 stats/feedback \u2192 secondary actions (shop, settings)\n- Mobile-first: everything should work on a 375px wide screen and scale up\n- Scrollable secondary content (shops, lists) should never push the main interaction off-screen\n\n### Visual Feedback\n- Every user interaction (click, purchase, hover) MUST produce visible feedback\n- Number changes should animate (bounce, scale pulse, color flash)\n- Buttons: press animation (scale 0.95), hover glow/lift, disabled state with reduced opacity\n- Success actions: green flash, checkmark, particle burst\n- Use CSS transitions (200-300ms) on all interactive elements\n\n### Color & Contrast\n- Dark backgrounds with bright accent colors for maximum contrast\n- Use gradients over flat colors for depth (e.g., purple-900 to indigo-950)\n- Interactive elements should be the brightest items on screen\n- Text must have sufficient contrast \u2014 white/yellow on dark, with text-shadow for readability\n\n### Typography\n- Big, bold numbers for scores/stats (text-4xl to text-6xl)\n- Clear hierarchy: title (bold, large) \u2192 subtitle (medium) \u2192 body (regular, smaller)\n- Monospace or tabular numbers for counters that change frequently\n\n### Animation\n- Idle animations (slow pulse, float, rotate) make the UI feel alive\n- Click animations should be fast (100-200ms) and snappy\n- Spawn/death animations for appearing/disappearing elements\n- Use CSS will-change on animated elements for performance\n\n### Cards & Containers\n- Rounded corners (border-radius: 12-16px)\n- Subtle shadows for depth (shadow-lg, shadow-xl)\n- Semi-transparent backgrounds with backdrop-blur for overlay panels\n- Hover: lift (translateY -2 to -4px) + shadow increase`;\n\n// \u2500\u2500\u2500 6. Magic UI component examples \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet magicDocs = '';\ntry {\n  const uiKeywords = [];\n  const taskDescs = tasks.map(t => (t.description || '').toLowerCase()).join(' ');\n  const uiPatterns = ['button', 'card', 'shop', 'form', 'input', 'modal', 'dialog', 'nav', 'header', 'footer', 'sidebar', 'menu', 'table', 'list', 'grid', 'dashboard', 'counter', 'score', 'game', 'animation', 'toggle', 'dropdown'];\n  for (const p of uiPatterns) {\n    if (taskDescs.includes(p)) uiKeywords.push(p);\n  }\n  if (uiKeywords.length > 0) {\n    const magicInit = await new Promise((resolve, reject) => {\n      const body = JSON.stringify({ jsonrpc: '2.0', method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'eek-go-magic', version: '1.0' } }, id: Date.now() });\n      const req = http.request({\n        hostname: 'docky', port: 8811, path: '/mcp', method: 'POST',\n        headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream', 'Content-Length': Buffer.byteLength(body) }\n      }, res => {\n        let data = ''; res.on('data', c => data += c);\n        res.on('end', () => resolve({ data, sessionId: res.headers['mcp-session-id'] }));\n      });\n      req.on('error', reject);\n      req.setTimeout(10000, () => { req.destroy(); reject(new Error('timeout')); });\n      req.write(body); req.end();\n    });\n    const magicSid = magicInit.sessionId;\n    if (magicSid) {\n      const searchTerms = uiKeywords.slice(0, 3).map(k => k + ' component');\n      for (const query of searchTerms) {\n        try {\n          const inspResp = await mcpRequest(magicSid, 'tools/call', {\n            name: '21st_magic_component_inspiration',\n            arguments: { message: 'I need a modern ' + query + ' for a web app', searchQuery: query }\n          });\n          const inspData = parseSSE(inspResp.data);\n          const inspText = (inspData.result?.content || []).map(c => c.text || '').join('');\n          if (inspText.length > 200) {\n            try {\n              const components = JSON.parse(inspText);\n              if (Array.isArray(components)) {\n                const examples = components.slice(0, 2).map(c => {\n                  const code = c.demoCode || c.code || '';\n                  const name = c.demoName || c.name || query;\n                  return '### ' + name + '\\n```tsx\\n' + code + '\\n```';\n                }).join('\\n\\n');\n                if (examples.length > 100) magicDocs += '\\n\\n## Magic UI: ' + query + '\\n' + examples;\n              }\n            } catch(e) {\n              if (inspText.length > 100) magicDocs += '\\n\\n## Magic UI: ' + query + '\\n' + inspText.substring(0, 4000);\n            }\n          }\n        } catch(e) {}\n      }\n    }\n  }\n} catch(e) {}\n\nif (magicDocs) docs.push('## UI Component Examples (from 21st.dev Magic)\\n' + magicDocs);\n\n// \u2500\u2500\u2500 7. Assemble final research docs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst sourceLabel = docSource === 'exa' ? 'Exa Web Search' : docSource === 'context7' ? 'Context7' : 'Design Guide Only';\nstaticData._researchDocs = docs.length > 0\n  ? designGuide + '\\n\\n---\\n\\n## Library Documentation (from ' + sourceLabel + ')\\n\\n' + docs.join('\\n\\n---\\n\\n')\n  : designGuide;\n\n// \u2500\u2500\u2500 8. Callback to Forge \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\ntry {\n  const projectId = $('Extract Input').first().json.project_id || staticData._currentProjectId || 'unknown';\n  const cbBody = JSON.stringify({\n    event: 'research_complete', project_id: projectId,\n    data: { libraries_fetched: toFetch, doc_count: docs.length, doc_chars: docs.reduce((s,d) => s+d.length, 0), source: docSource, sources: docSources, errors: errors.length ? errors : undefined }\n  });\n  const req = http.request({\n    hostname: 'forge', port: 3500, path: '/api/status-callback', method: 'POST',\n    headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Content-Length': Buffer.byteLength(cbBody) }\n  });\n  req.on('error', () => {});\n  req.write(cbBody); req.end();\n} catch(e) {}\n\nreturn [{ json: $json }];\n"
      }
    },
    {
      "parameters": {
        "jsCode": "// MODIFIED: Absorbs Split Into Chunks. Builds flat (task, chunk) queue.\n// Queue is passed through data pipeline (not static data) to avoid stale reads after HTTP nodes.\nconst tasks = $json.tasks || [];\nconst planDocument = $json.plan_document || '';\nconst plannerInput = $('Prepare Planner Input').first().json;\nconst projectId = plannerInput.project_id;\nconst projectGoal = plannerInput.project_goal;\n\n// Topological sort\nconst taskMap = new Map();\ntasks.forEach(t => taskMap.set(t.task_id, t));\nconst inDegree = new Map();\nconst dependents = new Map();\ntasks.forEach(t => {\n  inDegree.set(t.task_id, 0);\n  dependents.set(t.task_id, []);\n});\ntasks.forEach(t => {\n  const deps = (t.dependencies || []).filter(d => taskMap.has(d));\n  inDegree.set(t.task_id, deps.length);\n  deps.forEach(d => dependents.get(d).push(t.task_id));\n});\nconst topoQueue = [];\ntasks.forEach(t => {\n  if (inDegree.get(t.task_id) === 0) topoQueue.push(t.task_id);\n});\nconst sorted = [];\nwhile (topoQueue.length > 0) {\n  const id = topoQueue.shift();\n  sorted.push(taskMap.get(id));\n  for (const dep of (dependents.get(id) || [])) {\n    inDegree.set(dep, inDegree.get(dep) - 1);\n    if (inDegree.get(dep) === 0) topoQueue.push(dep);\n  }\n}\nif (sorted.length < tasks.length) {\n  const sortedIds = new Set(sorted.map(t => t.task_id));\n  tasks.forEach(t => { if (!sortedIds.has(t.task_id)) sorted.push(t); });\n}\n\n// Flatten into (task, chunk) queue \u2014 2 files per chunk\nconst chunkSize = 15;\nconst queue = [];\nfor (const task of sorted) {\n  const taskFiles = task.files || [];\n  const chunks = [];\n  for (let i = 0; i < taskFiles.length; i += chunkSize) {\n    chunks.push(taskFiles.slice(i, i + chunkSize));\n  }\n  if (taskFiles.length === 0) continue;  // skip tasks with no files assigned\n  for (const chunkFiles of chunks) {\n    queue.push({\n      task,\n      chunk_files: chunkFiles,\n      project_id: projectId,\n      project_goal: projectGoal,\n      plan_document: planDocument\n    });\n  }\n}\n\nconst staticData = $getWorkflowStaticData('global');\nstaticData.p2Results = [];\nstaticData.allTasks = sorted;\nstaticData._queueTotal = queue.length;\nstaticData._queueDone = 0;\n\nif (queue.length === 0) {\n  return [{ json: { task: { task_id: 'NONE', description: 'No tasks', files: [] }, chunk_files: [], project_id: projectId, project_goal: projectGoal, plan_document: planDocument, _p2Queue: [] } }];\n}\n// Pass remaining queue items through data pipeline instead of static data\nconst first = queue[0];\nfirst._p2Queue = queue.slice(1);\nreturn [{ json: first }];"
      },
      "id": "spread-tasks",
      "name": "Spread Tasks",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1850,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// P2: Stash Context \u2014 passes through all fields including _p2Queue for loop control\nconst staticData = $getWorkflowStaticData('global');\nstaticData._currentProjectId = $json.project_id;\nstaticData._currentTaskId = ($json.task || {}).task_id || '';\nstaticData._currentTask = $json.task || null;\nstaticData._currentProjectGoal = $json.project_goal || '';\nstaticData._currentQueue = $json._p2Queue || [];  // snapshot queue for this iteration\nreturn [{ json: {\n  task: $json.task,\n  chunk_files: $json.chunk_files,\n  project_id: $json.project_id,\n  project_goal: $json.project_goal,\n  plan_document: $json.plan_document,\n  _p2Queue: $json._p2Queue || []\n} }];"
      },
      "id": "p2-stash",
      "name": "P2: Stash Context",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2050,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Builds coder input \u2014 task files in full, other files as compact summaries\nconst staticData = $getWorkflowStaticData('global');\nconst stashed = $json;\nconst taskFiles = (stashed.task || {}).files || stashed.chunk_files || [];\nconst allFiles = staticData._allFileContents || [];\n\n// Full content for files in THIS task + closely related files (imports)\nconst taskFileSet = new Set(taskFiles.map(f => typeof f === 'string' ? f : f.path));\n\n// Also include files that task files import from\nfor (const file of allFiles) {\n  if (taskFileSet.has(file.path)) {\n    // Find imports in this file\n    const importMatches = (file.content || '').matchAll(/from\\s+['\"]([^'\"]+)['\"]/g);\n    for (const m of importMatches) {\n      const importPath = m[1];\n      if (importPath.startsWith('.')) {\n        // Resolve relative import to find the actual file\n        const dir = file.path.replace(/\\/[^\\/]+$/, '');\n        const resolved = importPath.replace(/^\\.\\//,'').replace(/^\\.\\.\\//, '');\n        for (const f of allFiles) {\n          if (f.path.includes(resolved) || f.path.replace(/\\.(ts|tsx|js|jsx)$/, '').endsWith(resolved)) {\n            taskFileSet.add(f.path);\n          }\n        }\n      }\n    }\n  }\n}\n\n// Always include package.json and main config files (small, critical for context)\nconst alwaysInclude = ['package.json', 'tsconfig.json', 'vite.config.js', 'tailwind.config.js', 'postcss.config.js', 'index.html'];\nfor (const f of alwaysInclude) {\n  const match = allFiles.find(a => a.path === f || a.path.endsWith('/' + f));\n  if (match) taskFileSet.add(match.path);\n}\n\n// Split: full content for task files, compact for everything else\nconst fullFiles = [];\nconst summaryFiles = [];\nfor (const file of allFiles) {\n  if (file.path.includes('node_modules')) continue;\n  if (taskFileSet.has(file.path)) {\n    fullFiles.push(file);\n  } else if (file.path.match(/\\.(ts|tsx|js|jsx|css|json|html)$/)) {\n    // Compact summary: path + first line (exports) + size\n    const firstLines = (file.content || '').split('\\n').slice(0, 3).join('\\n');\n    const exports = (file.content || '').match(/export\\s+(default\\s+)?(?:function|const|class)\\s+(\\w+)/g) || [];\n    summaryFiles.push({\n      path: file.path,\n      summary: exports.join(', ') || firstLines.substring(0, 100),\n      size: (file.content || '').length\n    });\n  }\n}\n\nconst existingFiles = fullFiles;\n\nconst task = { ...(stashed.task || {}) };\nif (taskFiles.length > 0) task.files = taskFiles;\n\n// Extract design tokens from reference HTML files (e.g., Stitch concepts)\nconst http = require('http');\nconst projectId = stashed.project_id;\nlet designSystem = staticData._stitchDesignSystem || '';\nlet referenceStyles = staticData._stitchStyles || '';\n\n// Only fetch if not already cached this run AND task involves UI/visual work\nconst taskDesc = ((stashed.task || {}).description || '').toLowerCase();\nconst isVisualTask = taskDesc.includes('design') || taskDesc.includes('style') || taskDesc.includes('layout') || taskDesc.includes('ui') || taskDesc.includes('color') || taskDesc.includes('component') || taskDesc.includes('css') || taskDesc.includes('tailwind') || taskDesc.includes('visual') || taskDesc.includes('mockup') || taskDesc.includes('reference');\n\nif (!designSystem && isVisualTask && projectId) {\n  try {\n    // Fetch reference files list\n    const refFiles = await new Promise((resolve) => {\n      const req = http.request({\n        hostname: 'file-api', port: 3456, method: 'GET',\n        path: '/projects/' + projectId + '/files',\n        headers: { 'Authorization': 'Bearer ' + ($env.FILE_API_TOKEN || '') },\n        timeout: 10000\n      }, res => {\n        let d = ''; res.on('data', c => d += c);\n        res.on('end', () => { try { resolve(JSON.parse(d).files || []); } catch { resolve([]); } });\n      });\n      req.on('error', () => resolve([]));\n      req.end();\n    });\n\n    // Find HTML files in references/\n    const htmlRefs = refFiles.filter(f => f.path.startsWith('references/') && f.path.endsWith('.html'));\n    \n    for (const ref of htmlRefs.slice(0, 2)) {\n      const content = ref.content || '';\n      if (content.length < 100) continue;\n      \n      // Extract Tailwind config\n      const configMatch = content.match(/tailwind\\.config\\s*=\\s*(\\{[\\s\\S]*?\\})\\s*<\\/script>/);\n      if (configMatch && !designSystem) {\n        designSystem = configMatch[1].substring(0, 4000);\n        staticData._stitchDesignSystem = designSystem;\n          \n          // Extract common component patterns from HTML and generate @apply classes\n          const classPatterns = [];\n          \n          // Find repeated class combinations in the HTML (buttons, cards, badges, etc.)\n          const classMatches = content.match(/class=\"([^\"]{30,})\"/g) || [];\n          const classCounts = {};\n          for (const match of classMatches) {\n            const classes = match.replace('class=\"', '').replace('\"', '').trim();\n            // Normalize whitespace\n            const normalized = classes.replace(/\\s+/g, ' ').trim();\n            classCounts[normalized] = (classCounts[normalized] || 0) + 1;\n          }\n          \n          // Find patterns used 2+ times \u2014 these are component-worthy\n          const componentClasses = [];\n          for (const [classes, count] of Object.entries(classCounts)) {\n            if (count >= 2 && classes.length > 20 && classes.length < 200) {\n              // Generate a semantic name from the classes\n              let name = 'component';\n              if (classes.includes('rounded-full') && classes.includes('px-')) name = 'pill';\n              else if (classes.includes('rounded-') && classes.includes('border')) name = 'card';\n              else if (classes.includes('font-bold') && classes.includes('text-')) name = 'heading';\n              else if (classes.includes('flex') && classes.includes('items-center')) name = 'row';\n              else if (classes.includes('grid')) name = 'grid';\n              else if (classes.includes('btn') || (classes.includes('cursor-pointer') && classes.includes('px-'))) name = 'btn';\n              else if (classes.includes('backdrop-blur')) name = 'glass';\n              \n              componentClasses.push({ name: name + '-' + componentClasses.length, classes, count });\n            }\n          }\n          \n          if (componentClasses.length > 0) {\n            // Generate @apply CSS\n            let applyCSS = '@layer components {\\n';\n            for (const comp of componentClasses.slice(0, 15)) {\n              applyCSS += '  .' + comp.name + ' {\\n    @apply ' + comp.classes.substring(0, 150) + ';\\n  }\\n';\n            }\n            applyCSS += '}';\n            staticData._stitchComponentClasses = applyCSS;\n          }\n      }\n      \n      // Extract style blocks\n      const styleMatches = content.match(/<style>([\\s\\S]*?)<\\/style>/g);\n      if (styleMatches && !referenceStyles) {\n        referenceStyles = styleMatches.join('\\n').substring(0, 2000);\n        staticData._stitchStyles = referenceStyles;\n      }\n    }\n  } catch(e) {}\n}\n\nreturn [{ json: {\n  task,\n  existing_files: existingFiles,\n  other_files_summary: summaryFiles,\n  plan_document: stashed.plan_document || '',\n  project_goal: stashed.project_goal || '',\n  project_id: stashed.project_id,\n  research_docs: staticData._researchDocs || '',\n  design_system: designSystem,\n  reference_styles: referenceStyles\n} }];"
      },
      "id": "p2-build-input",
      "name": "P2: Build Code Input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2250,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// CW: Prepare Message \u2014 restructured for instruction priority\n// Order: Task FIRST \u2192 Assets \u2192 Design System \u2192 Images \u2192 Files \u2192 Research (last)\nconst input = $json;\nconst planDocument = input.plan_document || '';\n\nlet taskDescription = input.task?.description || input.description || '';\nif (!taskDescription && input.task?.raw_output) {\n  const raw = input.task.raw_output;\n  const cleaned = raw.replace(/<\\/?task>/g, '').replace(/\\n+(?:Reasoning|Note):[\\s\\S]*/i, '').trim();\n  try {\n    const parsed = JSON.parse(cleaned);\n    const list = Array.isArray(parsed) ? parsed : (parsed.tasks || []);\n    taskDescription = list.map(t => `${t.task_id}: ${t.description}`).join('\\n\\n');\n  } catch(e) { taskDescription = raw; }\n}\n\nconst taskFiles = (input.task || {}).files || [];\nconst filesConstraint = taskFiles.length > 0\n  ? '\\n\\nFILES TO MODIFY (only output these files):\\n' + taskFiles.join('\\n')\n  : '';\n\nconst existingFiles = input.existing_files || [];\nconst otherFilesSummary = input.other_files_summary || [];\nconst existingFilesSection = existingFiles.length > 0\n  ? '\\n\\nEXISTING PROJECT FILES:\\n' +\n    (() => { let total = 0; const MAX = 40000; return existingFiles.filter(f => { const size = f.path.length + (f.content||'').length + 20; if (total + size > MAX) return false; total += size; return true; }).map(f => `### ${f.path}\\n\\`\\`\\`\\n${f.content}\\n\\`\\`\\``).join('\\n\\n'); })() + (otherFilesSummary.length > 0 ? '\\n\\nOTHER PROJECT FILES (not shown in full):\\n' + otherFilesSummary.map(f => `- ${f.path} (${f.size} chars): ${f.summary}`).join('\\n') : '')\n  : '';\n\nconst researchDocs = input.research_docs || '';\n\n// Detect image assets in public/assets/\nconst assetFiles = existingFiles\n  .filter(f => f.path.match(/^public\\/assets\\/.*\\.(png|jpg|jpeg|svg|gif|webp)$/i))\n  .map(f => f.path);\nconst assetWarning = assetFiles.length > 0\n  ? '\\n\\nAVAILABLE ASSETS (use <img src> for these, do NOT recreate them):\\n' +\n    assetFiles.map(a => `- ${a} \u2192 <img src=\"/${a.replace('public/', '')}\" />`).join('\\n')\n  : '';\n\n// Design system tokens from reference HTML\nconst designSystem = input.design_system || $getWorkflowStaticData('global')._stitchDesignSystem || '';\nconst stitchStyles = input.reference_styles || $getWorkflowStaticData('global')._stitchStyles || '';\n\n// Load reference images\nconst http = require('http');\nconst projectId = input.project_id || $('Extract Input').first().json.project_id || '';\nconst FILE_API = ($env.FILE_API_URL || 'http://file-api:3456').replace('http://', '');\nconst [apiHost, apiPort] = FILE_API.split(':');\nconst refImages = await new Promise((resolve) => {\n  const req = http.request({\n    hostname: apiHost, port: parseInt(apiPort) || 3456,\n    path: `/projects/${projectId}/references`,\n    method: 'GET',\n    headers: { 'Authorization': 'Bearer ' + ($env.FILE_API_TOKEN || '') },\n    timeout: 10000\n  }, (res) => {\n    let data = '';\n    res.on('data', d => data += d);\n    res.on('end', () => { try { resolve(JSON.parse(data).references || []); } catch { resolve([]); } });\n  });\n  req.on('error', () => resolve([]));\n  req.on('timeout', () => { req.destroy(); resolve([]); });\n  req.end();\n});\n\nconst webhookImage = $('Extract Input').first().json.image_data || null;\nconst taskId = (input.task || {}).task_id || '';\nconst taskConcepts = $getWorkflowStaticData('global')._taskConcepts || {};\nconst taskConcept = taskConcepts[taskId] || null;\n\n// \u2500\u2500 TASK-SPECIFIC MCP DOCS (fetch targeted docs for this task) \u2500\u2500\nconst taskLower = taskDescription.toLowerCase();\nif (taskLower.includes('gsap') || taskLower.includes('scrolltrigger') || taskLower.includes('animation') || taskLower.includes('scroll')) {\n  try {\n    const http = require('http');\n    const apis = [];\n    if (taskLower.includes('scrolltrigger') || taskLower.includes('scroll')) apis.push('ScrollTrigger');\n    if (taskLower.includes('timeline')) apis.push('gsap.timeline');\n    if (taskLower.includes('gsap.from') || taskLower.includes('opacity')) apis.push('gsap.from');\n    if (apis.length === 0) apis.push('ScrollTrigger', 'gsap.to');\n    \n    for (const api of apis) {\n      try {\n        const mcpBody = JSON.stringify({ jsonrpc: '2.0', method: 'tools/call', params: { name: 'get_gsap_api_expert', arguments: { api_element: api, level: 'intermediate' } }, id: Date.now() });\n        const initBody = JSON.stringify({ jsonrpc: '2.0', method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'eek-coder', version: '1.0' } }, id: Date.now() });\n        \n        // Init session\n        const sid = await new Promise((resolve) => {\n          const req = http.request({ hostname: 'docky', port: 8811, path: '/mcp', method: 'POST',\n            headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream', 'Content-Length': Buffer.byteLength(initBody) }\n          }, res => {\n            resolve(res.headers['mcp-session-id'] || '');\n            res.resume();\n          });\n          req.on('error', () => resolve(''));\n          req.setTimeout(5000, () => { req.destroy(); resolve(''); });\n          req.write(initBody); req.end();\n        });\n        \n        if (!sid) continue;\n        \n        const result = await new Promise((resolve) => {\n          const req = http.request({ hostname: 'docky', port: 8811, path: '/mcp', method: 'POST',\n            headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream', 'Content-Length': Buffer.byteLength(mcpBody), 'Mcp-Session-Id': sid }\n          }, res => {\n            let data = ''; res.on('data', c => data += c);\n            res.on('end', () => {\n              for (const line of data.split('\\n')) {\n                if (line.startsWith('data: ') || line.startsWith('data:')) {\n                  try { const d = JSON.parse(line.replace(/^data:\\s?/, '')); \n                    const text = (d.result?.content || []).map(c => c.text || '').join('');\n                    if (text.length > 100 && !text.includes('Error')) resolve(text);\n                    else resolve('');\n                  } catch { resolve(''); }\n                  return;\n                }\n              }\n              resolve('');\n            });\n          });\n          req.on('error', () => resolve(''));\n          req.setTimeout(10000, () => { req.destroy(); resolve(''); });\n          req.write(mcpBody); req.end();\n        });\n        \n        if (result) {\n          researchDocs = (researchDocs ? researchDocs + '\\n\\n' : '') + '## GSAP: ' + api + '\\n' + result.substring(0, 3000);\n        }\n      } catch {}\n    }\n  } catch {}\n}\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// BUILD THE PROMPT \u2014 instruction-first order\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\n// SYSTEM: Short, focused rules only\nconst systemMessage = `You are a Senior Full-Stack Developer. Output ONLY code file blocks.\n\nENGINEERING PRINCIPLES:\n- Fix root causes, never work around errors or suppress them\n- Keep it simple \u2014 write the minimum code needed, no over-engineering\n- Don't add features, abstractions, or utilities that weren't asked for\n- Match existing patterns \u2014 if the project uses X style, follow X style\n- One component/function = one responsibility\n- Name things clearly \u2014 if you need a comment to explain a variable name, rename it\n- No dead code, no commented-out code, no console.logs left behind\n- Handle errors at system boundaries (user input, API calls), trust internal code\n- Prefer modifying existing files over creating new ones\n- DRY \u2014 but three similar lines are better than a premature abstraction\n\nFORMAT \u2014 two modes depending on whether the file exists:\n\nFOR EXISTING FILES \u2014 use SEARCH/REPLACE blocks (targeted edits, not full rewrites):\nFILE: path/to/file.ext\n<<<<<<< SEARCH\n[exact lines to find \u2014 copy from the existing file precisely]\n=======\n[replacement lines]\n>>>>>>> REPLACE\n\nYou can have multiple SEARCH/REPLACE blocks per file. Each block changes one section.\nThe SEARCH text must match the existing file EXACTLY \u2014 same whitespace, same indentation.\nInclude enough context lines (3-5) around the change to make the match unique.\n\nFOR NEW FILES (not in existing project) \u2014 use full content:\n### path/to/file.ext\n\\`\\`\\`ext\n[complete file content]\n\\`\\`\\`\n\nRULES:\n- For EXISTING files: use SEARCH/REPLACE blocks. For NEW files: output complete content\n- Only output files listed in FILES TO MODIFY\n- No explanations, no prose \u2014 ONLY file blocks\n- Files with JSX MUST use .tsx extension\n- NEVER remove @tailwind directives from CSS files\n- NEVER remove existing imports that are still used\n- When modifying a file, preserve everything that works \u2014 only change what the task asks for\n- NEVER rewrite a file from scratch. Keep the existing structure, imports, hooks, and component logic intact. Only modify the specific lines related to the task\n- If a file uses useGame(), keep useGame(). If it imports poop.png, keep poop.png. If it has a specific component structure, keep that structure`;\n\n// USER: Task first, then supporting context\nconst content = [];\n\n// \u2500\u2500 1. TASK DESCRIPTION (highest priority \u2014 read this first) \u2500\u2500\ncontent.push({ type: 'text', text: `YOUR TASK:\\n${taskDescription}${assetWarning}${filesConstraint}` });\n\n// \u2500\u2500 1.5 PROJECT MEMORY (continuity from previous runs) \u2500\u2500\nconst projectMemory = $getWorkflowStaticData('global')._projectMemory || '';\nif (projectMemory) {\n  content.push({ type: 'text', text: 'PROJECT CONTEXT (from previous pipeline runs \u2014 do NOT create or modify memory.md):\\n' + projectMemory.substring(0, 3000) });\n}\n\n// \u2500\u2500 2. DESIGN SYSTEM (if available \u2014 use these tokens) \u2500\u2500\nif (designSystem) {\n  content.push({ type: 'text', text: 'DESIGN SYSTEM (follow these design principles, color palette, font choices, and spacing patterns):\\n' + designSystem.substring(0, 3000) });\n}\nif (stitchStyles) {\n  content.push({ type: 'text', text: 'REFERENCE CSS PATTERNS:\\n' + stitchStyles.substring(0, 1500) });\n}\nconst componentClasses = $getWorkflowStaticData('global')._stitchComponentClasses || '';\nif (componentClasses) {\n  content.push({ type: 'text', text: 'COMPONENT CLASSES (add these to your CSS file and use the semantic class names instead of repeating utility classes):\\n' + componentClasses });\n}\n\n// \u2500\u2500 3. REFERENCE IMAGES (visual target) \u2500\u2500\nif (refImages.length > 0) {\n  for (const ref of refImages) {\n    content.push({ type: 'image_url', image_url: { url: 'data:image/png;base64,' + ref.base64 } });\n    const label = ref.filename.replace(/\\.(png|jpg|jpeg|gif|webp)$/i, '').replace(/[_-]/g, ' ');\n    content.push({ type: 'text', text: 'REFERENCE (' + label + '):' });\n  }\n} else if (webhookImage) {\n  content.push({ type: 'image_url', image_url: { url: 'data:image/png;base64,' + webhookImage } });\n  content.push({ type: 'text', text: 'REFERENCE DESIGN:' });\n}\nif (taskConcept) {\n  content.push({ type: 'image_url', image_url: { url: 'data:image/png;base64,' + taskConcept } });\n  content.push({ type: 'text', text: 'TASK CONCEPT (visual guide for this task):' });\n}\n\n// \u2500\u2500 4. EXISTING FILES (context for the coder) \u2500\u2500\nif (existingFilesSection) {\n  content.push({ type: 'text', text: existingFilesSection });\n}\n\n// \u2500\u2500 5. ARCHITECTURE + RESEARCH (lowest priority \u2014 background context) \u2500\u2500\nif (planDocument) {\n  content.push({ type: 'text', text: 'ARCHITECTURE:\\n' + planDocument.substring(0, 3000) });\n}\nif (researchDocs) {\n  content.push({ type: 'text', text: 'LIBRARY DOCS:\\n' + researchDocs.substring(0, 6000) });\n}\n\nconst messageContent = content.length > 1 ? content : content[0].text;\n\nreturn [{ json: {\n  model: $env.CODER_MODEL || 'qwen3.5-27b@q4_k_m',\n  messages: [\n    { role: 'system', content: systemMessage },\n    { role: 'user', content: messageContent }\n  ],\n  temperature: 0.6,\n  chat_template_kwargs: { enable_thinking: false },\n  top_p: 0.95,\n    top_k: 20,\n    min_p: 0.0,\n    presence_penalty: 0.0,\n  max_tokens: parseInt($env.CODER_MAX_TOKENS) || 32768\n} }];"
      },
      "id": "cw-prepare",
      "name": "CW: Prepare Message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2450,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const msg = $json.choices[0].message || {};\nlet raw = msg.content || '';\nconst reasoning = msg.reasoning_content || '';\n\n// Qwen3.5-27B may put output in reasoning_content or <think> tags\nlet content = raw.replace(/<think>[\\s\\S]*?<\\/think>/g, '').trim();\nif (content.includes('</think>')) {\n  content = content.split('</think>').pop().trim();\n}\nif (content.length < 50 && reasoning.length > 50) {\n  content = reasoning.replace(/<think>[\\s\\S]*?<\\/think>/g, '').trim();\n  if (content.includes('</think>')) content = content.split('</think>').pop().trim();\n}\nif (content.length < 50 && raw.length > 50) {\n  content = raw.replace(/<think>/g, '').replace(/<\\/think>/g, '').trim();\n}\n\nconst files = [];\nconst edits = []; // search/replace edits for existing files\nconst seen = new Set();\n\n// \u2500\u2500 1. Parse SEARCH/REPLACE blocks (for existing files) \u2500\u2500\nconst editRe = /(?:FILE:|###)\\s*([\\w./-]+(?:\\.[\\w]+))\\s*\\n<<<<<<< SEARCH\\n([\\s\\S]*?)\\n=======\\n([\\s\\S]*?)\\n>>>>>>> REPLACE/g;\nlet em;\nwhile ((em = editRe.exec(content)) !== null) {\n  const path = em[1].trim();\n  const search = em[2];\n  const replace = em[3];\n  edits.push({ path, search, replace });\n}\n\n// \u2500\u2500 2. Parse full file blocks (for new files) \u2500\u2500\nconst fileRe = /###\\s+((?:[\\w.-]+\\/)*(?:\\.[\\w][\\w.-]*|[\\w.-]+\\.(?:ts|tsx|js|jsx|json|md|yml|yaml|env|prisma|css|html|sh|txt|lock|toml|cfg|ini)|Dockerfile|Makefile|LICENSE|CHANGELOG))\\s*\\n```[\\w]*\\n([\\s\\S]*?)```/g;\nlet fm;\nwhile ((fm = fileRe.exec(content)) !== null) {\n  const path = fm[1].trim();\n  const fileContent = fm[2];\n  if (path && !seen.has(path)) {\n    seen.add(path);\n    files.push({ path, content: fileContent });\n  }\n}\n\n// \u2500\u2500 3. Apply search/replace edits to existing file contents \u2500\u2500\nconst staticData = $getWorkflowStaticData('global');\nconst allFiles = staticData._allFileContents || [];\n\n// Group edits by file path\nconst editsByFile = {};\nfor (const edit of edits) {\n  if (!editsByFile[edit.path]) editsByFile[edit.path] = [];\n  editsByFile[edit.path].push(edit);\n}\n\n// Apply edits to each file\nfor (const [path, fileEdits] of Object.entries(editsByFile)) {\n  if (seen.has(path)) continue; // full file block takes precedence\n  \n  // Find existing file content\n  const existing = allFiles.find(f => f.path === path);\n  if (!existing) {\n    // File doesn't exist \u2014 can't apply edits, skip\n    continue;\n  }\n  \n  let fileContent = existing.content;\n  let applied = 0;\n  \n  for (const edit of fileEdits) {\n    if (fileContent.includes(edit.search)) {\n      fileContent = fileContent.replace(edit.search, edit.replace);\n      applied++;\n    } else {\n      // Try with trimmed whitespace matching (fuzzy)\n      const searchTrimmed = edit.search.split('\\n').map(l => l.trim()).join('\\n');\n      const contentLines = fileContent.split('\\n');\n      let found = false;\n      \n      for (let i = 0; i <= contentLines.length - edit.search.split('\\n').length; i++) {\n        const window = contentLines.slice(i, i + edit.search.split('\\n').length);\n        const windowTrimmed = window.map(l => l.trim()).join('\\n');\n        if (windowTrimmed === searchTrimmed) {\n          // Found fuzzy match \u2014 replace preserving original indentation of first line\n          const replaceLines = edit.replace.split('\\n');\n          contentLines.splice(i, edit.search.split('\\n').length, ...replaceLines);\n          fileContent = contentLines.join('\\n');\n          applied++;\n          found = true;\n          break;\n        }\n      }\n    }\n  }\n  \n  if (applied > 0) {\n    seen.add(path);\n    files.push({ path, content: fileContent });\n  }\n}\n\n// \u2500\u2500 4. Post-process: if any \"full file\" content has SEARCH/REPLACE markers inside, apply them \u2500\u2500\nconst processedFiles = [];\nfor (const file of files) {\n  if (file.content && file.content.includes('<<<<<<< SEARCH')) {\n    // This file was wrapped in code fence but contains search/replace blocks\n    const existing = allFiles.find(f => f.path === file.path);\n    if (existing) {\n      let fc = existing.content;\n      const blockRe = /<<<<<<< SEARCH\\n([\\s\\S]*?)\\n=======\\n([\\s\\S]*?)\\n>>>>>>> REPLACE/g;\n      let bm;\n      let applied = 0;\n      while ((bm = blockRe.exec(file.content)) !== null) {\n        const search = bm[1];\n        const replace = bm[2];\n        if (fc.includes(search)) {\n          fc = fc.replace(search, replace);\n          applied++;\n        } else {\n          // Fuzzy match\n          const st = search.split('\\n').map(l => l.trim()).join('\\n');\n          const cl = fc.split('\\n');\n          for (let i = 0; i <= cl.length - search.split('\\n').length; i++) {\n            if (cl.slice(i, i + search.split('\\n').length).map(l => l.trim()).join('\\n') === st) {\n              cl.splice(i, search.split('\\n').length, ...replace.split('\\n'));\n              fc = cl.join('\\n');\n              applied++;\n              break;\n            }\n          }\n        }\n      }\n      if (applied > 0) {\n        processedFiles.push({ path: file.path, content: fc });\n        continue;\n      }\n    }\n  }\n  processedFiles.push(file);\n}\n// Replace files array with processed version\nfiles.length = 0;\nfiles.push(...processedFiles);\n\nconst projectId = staticData._currentProjectId || $('Extract Input').first().json.project_id || 'unknown';\nconst task = staticData._currentTask || {};\nconst projectGoal = staticData._currentProjectGoal || $('Extract Input').first().json.message || '';\nreturn [{ json: { files, edits_applied: edits.length, edits_total: edits.length, task, project_id: projectId, project_goal: projectGoal } }];"
      },
      "id": "cw-parse",
      "name": "CW: Parse Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2850,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// NEW: Replaces Chunk: Prepare Write\n// Simplified write prep + updates _allFileContents cache so later chunks see fresh content\nconst stashed = $('P2: Stash Context').first().json;\nconst chunkFiles = stashed.chunk_files || [];\nconst newFiles = $json.files || [];\nconst staticData2 = $getWorkflowStaticData('global');\nconst projectId = $json.project_id || staticData2._currentProjectId || stashed.project_id;\n\n// Include all files the Code Writer produced\nlet filteredFiles = newFiles;\n\n// Never let coder overwrite memory.md \u2014 pipeline-managed\nfilteredFiles = filteredFiles.filter(f => f.path !== 'memory.md');\n\n// Handle package.json merge: preserve existing deps, add new ones\nconst staticData = $getWorkflowStaticData('global');\nconst allFiles = staticData._allFileContents || [];\nfor (const newFile of filteredFiles) {\n  if (newFile.path === 'package.json' || newFile.path.endsWith('/package.json')) {\n    const existing = allFiles.find(f => f.path === newFile.path);\n    if (existing) {\n      try {\n        const existingPkg = JSON.parse(existing.content);\n        const newPkg = JSON.parse(newFile.content);\n        newPkg.dependencies = { ...(existingPkg.dependencies || {}), ...(newPkg.dependencies || {}) };\n        newPkg.devDependencies = { ...(existingPkg.devDependencies || {}), ...(newPkg.devDependencies || {}) };\n        newFile.content = JSON.stringify(newPkg, null, 2);\n      } catch(e) {}\n    }\n  }\n}\n\n// Update the in-memory file cache so later chunks see fresh content\nfor (const newFile of filteredFiles) {\n  const idx = allFiles.findIndex(f => f.path === newFile.path);\n  if (idx >= 0) {\n    allFiles[idx] = { path: newFile.path, content: newFile.content };\n  } else {\n    allFiles.push({ path: newFile.path, content: newFile.content });\n  }\n}\nstaticData._allFileContents = allFiles;\n\nif (filteredFiles.length === 0) {\n  return [{ json: { project_id: projectId, files: [], _skipWrite: true } }];\n}\n\nreturn [{ json: { project_id: projectId, files: filteredFiles } }];"
      },
      "id": "p2-prepare-write",
      "name": "P2: Prepare Write",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3050,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// P2: Store Result \u2014 uses $json like all other Code nodes\nconst staticData = $getWorkflowStaticData('global');\nstaticData._queueDone = (staticData._queueDone || 0) + 1;\nconst total = staticData._queueTotal || 0;\nconst done = staticData._queueDone;\nconst remaining = total - done;\n\nconst taskId = staticData._currentTaskId || 'unknown';\nconst taskFiles = (staticData._currentTask || {}).files || [];\nconst writtenFiles = ($json.files_written || $json.files || []).map(f => typeof f === 'string' ? f : (f.path || JSON.stringify(f)));\nif (!staticData.p2Results) staticData.p2Results = [];\nstaticData.p2Results.push({ task_id: taskId, files_written: writtenFiles });\n\n\n// Status callback to Forge \u2014 fire and forget\ntry {\n  const http = require('http');\n  const cbBody = JSON.stringify({\n    event: 'task_written',\n    project_id: staticData._currentProjectId || 'unknown',\n    data: { task_id: taskId, files: writtenFiles.length > 0 ? writtenFiles : taskFiles }\n  });\n  const cbReq = http.request({\n    hostname: 'forge', port: 3500, path: '/api/status-callback',\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(cbBody) }\n  });\n  cbReq.on('error', () => {});\n  cbReq.write(cbBody);\n  cbReq.end();\n} catch(e) {}\n\nconst remainingQueue = staticData._currentQueue || [];\nconst isDone = remaining <= 0 || remainingQueue.length === 0;\n\nlet nextItem = null;\nif (!isDone) {\n  nextItem = JSON.parse(JSON.stringify(remainingQueue[0]));\n  nextItem._p2Queue = remainingQueue.slice(1);\n}\n\nreturn [{ json: {\n  _done: isDone,\n  _nextItem: nextItem,\n  _remaining: remaining,\n  task_id: taskId,\n  files_written: writtenFiles\n} }];"
      },
      "id": "p2-store",
      "name": "P2: Store Result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3450,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const http = require('http');\nconst projectId = $json.project_id || 'unknown';\nconst message = $json.message || '';\ntry {\n  const body = JSON.stringify({ event: 'pipeline_started', project_id: projectId, data: { message } });\n  const req = http.request({ hostname: 'forge', port: 3500, path: '/api/status-callback', method: 'POST',\n    headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } });\n  req.on('error', () => {});\n  req.write(body); req.end();\n} catch {}\nreturn [{ json: $json }];"
      },
      "id": "cb-started",
      "name": "CB: Pipeline Started",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        300,
        500
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ ($env.LM_STUDIO_HOST || 'http://10.0.0.100:1234') + '/api/v1/models/load' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($env.LLM_API_KEY || '') }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: $env.CODER_MODEL || 'qwen3.5-27b@q4_k_m', context_length: parseInt($env.CODER_CTX) || 65536 }) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "load-model",
      "name": "Load Model",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1100,
        100
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.LM_STUDIO_URL || 'http://10.0.0.100:1234/v1/chat/completions' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($env.LLM_API_KEY || '') }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json) }}",
        "options": {
          "timeout": 600000
        }
      },
      "id": "planner-llm",
      "name": "Planner: Call LM Studio",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1400,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const http = require('http');\nconst staticData = $getWorkflowStaticData('global');\nconst projectId = staticData._currentProjectId || $('Extract Input').first().json.project_id || 'unknown';\nconst tasks = $json.tasks || [];\ntry {\n  const body = JSON.stringify({ event: 'planning_complete', project_id: projectId, data: { task_count: tasks.length } });\n  const req = http.request({ hostname: 'forge', port: 3500, path: '/api/status-callback', method: 'POST',\n    headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } });\n  req.on('error', () => {});\n  req.write(body); req.end();\n} catch {}\nreturn [{ json: $json }];"
      },
      "id": "cb-planning",
      "name": "CB: Planning Complete",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1700,
        500
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.LM_STUDIO_URL || 'http://10.0.0.100:1234/v1/chat/completions' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($env.LLM_API_KEY || '') }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json) }}",
        "options": {
          "timeout": 600000
        }
      },
      "id": "cw-llm",
      "name": "CW: Call LM Studio",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2600,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ ($env.FILE_API_URL || 'http://file-api:3456') + '/projects/' + $json.project_id + '/files-batch' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($env.FILE_API_TOKEN || '') }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ files: $json.files }) }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "p2-write-files",
      "name": "P2: Write Files",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3200,
        300
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "if ($json._done) return [];\nreturn [{ json: $json._nextItem }];"
      },
      "id": "p2-continue",
      "name": "P2: Continue Gate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3600,
        200
      ]
    },
    {
      "parameters": {
        "jsCode": "if (!$json._done) return [];\nreturn [{ json: $json }];"
      },
      "id": "p2-exit",
      "name": "P2: Exit Gate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3600,
        400
      ]
    },
    {
      "parameters": {
        "jsCode": "const http = require('http');\nconst staticData = $getWorkflowStaticData('global');\nconst projectId = $('Extract Input').first().json.project_id || 'unknown';\n\n// Run vite build\nlet buildResult = { success: true, error: '' };\ntry {\n  const result = await new Promise((resolve) => {\n    const req = http.request({\n      hostname: 'file-api', port: 3456, method: 'POST',\n      path: '/projects/' + projectId + '/build-check',\n      headers: { 'Authorization': 'Bearer ' + ($env.FILE_API_TOKEN || ''), 'Content-Type': 'application/json', 'Content-Length': 0 },\n      timeout: 180000\n    }, res => {\n      let data = '';\n      res.on('data', c => data += c);\n      res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ success: false, error: data }); } });\n    });\n    req.on('error', e => resolve({ success: false, error: e.message }));\n    req.setTimeout(180000, () => { req.destroy(); resolve({ success: false, error: 'Build timed out' }); });\n    req.end();\n  });\n  buildResult = result;\n} catch(e) {\n  buildResult = { success: false, error: e.message };\n}\n\nstaticData._buildResult = buildResult;\nstaticData._buildAttempt = (staticData._buildAttempt || 0) + 1;\n\n// Callback\ntry {\n  const cbBody = JSON.stringify({\n    event: buildResult.success ? 'build_check_passed' : 'build_check_failed',\n    project_id: projectId,\n    data: { success: buildResult.success, error: buildResult.error || null }\n  });\n  const req = http.request({\n    hostname: 'forge', port: 3500, path: '/api/status-callback', method: 'POST',\n    headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(cbBody) }\n  });\n  req.on('error', () => {});\n  req.write(cbBody); req.end();\n} catch {}\n\nreturn [{ json: { ...($json), build_success: buildResult.success, build_error: buildResult.error || '', build_attempt: staticData._buildAttempt } }];"
      },
      "id": "build-check",
      "name": "Build Check",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3900,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Route: if build failed AND attempts < 3, try auto-fix\n// Otherwise continue to code review\nconst buildSuccess = $json.build_success;\nconst attempt = $json.build_attempt || 1;\n\nreturn [{ json: { ...$json, _needsAutoFix: !buildSuccess && attempt < 3, _buildPassed: buildSuccess } }];"
      },
      "id": "build-route",
      "name": "Build Route",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4100,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "if (!$json._needsAutoFix) return [];\nreturn [{ json: $json }];"
      },
      "id": "autofix-gate",
      "name": "Auto-Fix Gate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4300,
        200
      ]
    },
    {
      "parameters": {
        "jsCode": "if ($json._needsAutoFix) return [];\nreturn [{ json: $json }];"
      },
      "id": "buildpass-gate",
      "name": "Build Pass Gate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4300,
        400
      ]
    },
    {
      "parameters": {
        "jsCode": "const staticData = $getWorkflowStaticData('global');\nconst buildError = staticData._buildResult?.error || 'Unknown build error';\nconst allFiles = staticData._allFileContents || [];\nconst projectId = $('Extract Input').first().json.project_id || 'unknown';\n\n// Find files mentioned in the error\nconst errorFiles = [];\nconst errorMatch = buildError.match(/src\\/[^\\s:]+/g);\nif (errorMatch) {\n  for (const path of errorMatch) {\n    const file = allFiles.find(f => f.path === path || f.path.endsWith(path));\n    if (file) errorFiles.push(file);\n  }\n}\n\n// Include config files too\nconst configFiles = allFiles.filter(f => ['package.json', 'tsconfig.json', 'vite.config.ts', 'vite.config.js'].includes(f.path));\nconst contextFiles = [...new Map([...errorFiles, ...configFiles].map(f => [f.path, f])).values()];\n\nconst filesSection = contextFiles.map(f => '### ' + f.path + '\\n```\\n' + (f.content || '') + '\\n```').join('\\n\\n');\n\nconst systemMsg = `You are a Senior Full-Stack Developer. Fix the build error below.\n\nENGINEERING PRINCIPLES:\n- Fix root causes, never work around errors or suppress them\n- Keep it simple \u2014 write the minimum code needed, no over-engineering\n- Don't add features, abstractions, or utilities that weren't asked for\n- Match existing patterns \u2014 if the project uses X style, follow X style\n- One component/function = one responsibility\n- Name things clearly \u2014 if you need a comment to explain a variable name, rename it\n- No dead code, no commented-out code, no console.logs left behind\n- Handle errors at system boundaries (user input, API calls), trust internal code\n- Prefer modifying existing files over creating new ones\n- DRY \u2014 but three similar lines are better than a premature abstraction Output ONLY the fixed file blocks in ### path format. Do NOT remove @tailwind directives. Do NOT remove existing exports.`;\n\n// Add known issues from project memory if relevant\nconst projectMemory = staticData._projectMemory || '';\nlet memoryHint = '';\nif (projectMemory) {\n  const issuesMatch = projectMemory.match(/## Known Issues[\\s\\S]*?(?=\\n## |$)/);\n  const archMatch = projectMemory.match(/## Architecture[\\s\\S]*?(?=\\n## |$)/);\n  if (issuesMatch) memoryHint += issuesMatch[0].substring(0, 500) + '\\n';\n  if (archMatch) memoryHint += archMatch[0].substring(0, 500) + '\\n';\n}\n\n// Include research docs so auto-fix doesn't hallucinate APIs\nconst researchDocs = staticData._researchDocs || '';\nlet librarySection = '';\nif (researchDocs) librarySection = '\\n\\nLIBRARY DOCS (use these APIs, do NOT hallucinate methods):\\n' + researchDocs.substring(0, 6000);\n\nconst userMsg = 'BUILD ERROR:\\n' + buildError.substring(0, 1000) + '\\n\\nFILES:\\n' + filesSection + librarySection + (memoryHint ? '\\n\\nPROJECT CONTEXT:\\n' + memoryHint : '');\n\nreturn [{ json: {\n  model: $env.CODER_MODEL || 'qwen3.5-27b@q4_k_m',\n  messages: [\n    { role: 'system', content: systemMsg },\n    { role: 'user', content: userMsg }\n  ],\n  temperature: 0.6,\n  top_p: 0.95,\n  top_k: 20,\n  max_tokens: parseInt($env.FIXER_MAX_TOKENS) || 32768,\n  _project_id: projectId\n} }];"
      },
      "id": "autofix-prepare",
      "name": "Auto-Fix: Prepare",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4900,
        200
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.LM_STUDIO_URL || 'http://10.0.0.100:1234/v1/chat/completions' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($env.LLM_API_KEY || '') }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json) }}",
        "options": {
          "timeout": 600000
        }
      },
      "id": "autofix-llm",
      "name": "Auto-Fix: Call LM Studio",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5100,
        200
      ]
    },
    {
      "parameters": {
        "jsCode": "const msg = $json.choices?.[0]?.message || {};\nlet raw = msg.content || '';\nconst reasoning = msg.reasoning_content || '';\nif (raw.length < 50 && reasoning.length > 50) raw = reasoning;\nif (raw.length < 50 && (msg.content || '').length > 50) raw = (msg.content || '').replace(/<think>[\\s\\S]*?<\\/think>/g, '').trim();\n\nconst files = [];\nconst seen = new Set();\nconst re = /###\\s+((?:[\\w.-]+\\/)*(?:\\.[\\w][\\w.-]*|[\\w.-]+\\.(?:ts|tsx|js|jsx|json|md|yml|yaml|env|prisma|css|html|sh|txt|lock|toml|cfg|ini)|Dockerfile|Makefile|LICENSE|CHANGELOG))\\s*\\n```[\\w]*\\n([\\s\\S]*?)```/g;\nlet m;\nwhile ((m = re.exec(raw)) !== null) {\n  const path = m[1].trim();\n  const fileContent = m[2];\n  if (path && !seen.has(path)) {\n    seen.add(path);\n    files.push({ path, content: fileContent });\n  }\n}\n\n// Filter to existing files only\nconst staticData = $getWorkflowStaticData('global');\nconst allFiles = staticData._allFileContents || [];\nconst existingPaths = new Set(allFiles.map(f => f.path));\nconst filteredFiles = files.filter(f => existingPaths.has(f.path));\n\n// Update cache\nfor (const newFile of filteredFiles) {\n  const idx = allFiles.findIndex(f => f.path === newFile.path);\n  if (idx >= 0) allFiles[idx] = { path: newFile.path, content: newFile.content };\n  else allFiles.push({ path: newFile.path, content: newFile.content });\n}\nstaticData._allFileContents = allFiles;\n\nconst projectId = $('Extract Input').first().json.project_id || 'unknown';\n\nif (filteredFiles.length === 0) {\n  return [{ json: { project_id: projectId, files: [], _skipWrite: true } }];\n}\nreturn [{ json: { project_id: projectId, files: filteredFiles } }];"
      },
      "id": "autofix-parse",
      "name": "Auto-Fix: Parse",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5300,
        200
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ ($env.FILE_API_URL || 'http://file-api:3456') + '/projects/' + $json.project_id + '/files-batch' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($env.FILE_API_TOKEN || '') }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ files: $json.files || [] }) }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "autofix-write",
      "name": "Auto-Fix: Write Files",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5500,
        200
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const staticData = $getWorkflowStaticData('global');\nconst allFiles = staticData._allFileContents || [];\nconst buildResult = staticData._buildResult || {};\n\n// Get the user's original request so the reviewer can verify it was addressed\nconst userRequest = $('Extract Input').first().json.message || $('Extract Input').first().json.goal || '';\n\nconst buildSection = buildResult.success\n  ? 'BUILD STATUS: PASSED'\n  : 'BUILD STATUS: FAILED \u2014 ' + (buildResult.error || 'unknown').substring(0, 300);\n\n// Include console errors from Playwright check\nconst consoleResult = staticData._consoleResult || {};\nconst consoleErrors = consoleResult.errors || [];\nconst consoleWarnings = consoleResult.warnings || [];\nconst pageError = consoleResult.page_error || null;\n\nlet consoleSection = '';\nif (pageError) {\n  consoleSection = '\\nRUNTIME CHECK: PAGE CRASH\\nUncaught error: ' + pageError + '\\n';\n} else if (consoleErrors.length > 0) {\n  consoleSection = '\\nRUNTIME CHECK: CONSOLE ERRORS\\n' +\n    consoleErrors.map(e => '- [' + (e.type || 'error') + '] ' + e.message).join('\\n') + '\\n';\n  if (consoleWarnings.length > 0) {\n    consoleSection += '\\nCONSOLE WARNINGS:\\n' + consoleWarnings.map(w => '- ' + w.message).join('\\n') + '\\n';\n  }\n} else {\n  consoleSection = '\\nRUNTIME CHECK: CLEAN (no console errors)\\n';\n}\n\nconst fileSummaries = allFiles\n  .filter(f => f.path.match(/\\.(ts|tsx|js|jsx|json|css|html)$/) && !f.path.includes('node_modules') && !f.path.startsWith('references/'))\n  .map(f => '### ' + f.path + '\\n```\\n' + (f.content || '') + '\\n```')\n  .join('\\n\\n')\n  .substring(0, 80000);\n\nconst prompt = `You are a Senior Code Reviewer. Review this project for CRITICAL bugs only.\n\nUSER REQUEST (what was asked for this iteration):\n` + userRequest.substring(0, 500) + `\n\nIMPORTANT: Verify that the code changes actually address the user's request above. If the user asked to fix X and the code doesn't fix X, that is a critical issue. Score accordingly.\n\n` + buildSection + consoleSection + `\n\nPROJECT FILES:\n` + fileSummaries + `\n\nCRITICAL: Respond with ONLY a JSON object. No analysis, no explanation. Start with { immediately:\\n{\\n  \"code_quality\": number (0-100),\\n  \"suggestions\": [\"1-2 sentence actionable next step\"],\\n  \"issues\": [\\n    {\\n      \"file\": \"path\",\\n      \"severity\": \"critical|high\",\\n      \"issue\": \"description\",\\n      \"problem\": \"impact\"\\n    }\\n  ]\\n}\\n\\nOnly flag CRITICAL bugs: missing exports, import mismatches, runtime crashes, console errors, type errors, AND visual problems visible in the screenshot.\n\nSCORING RULES:\n- If the screenshot shows blank/invisible sections or missing content: score MUST be below 50\n- If any section heading exists but its content below is empty/invisible: score MUST be below 60\n- If the page crashes or shows errors: score MUST be below 30\n- If the page loads but has minor issues: score 60-80\n- Only score above 80 if ALL sections render visible content with no blank areas\n\nPrioritize what you SEE in the screenshot \u2014 if content is missing or invisible, that is a critical bug. Max 5 issues. Do NOT flag style preferences or optimization suggestions.`;\n\n// Build multimodal message if screenshot available\nconst screenshot = (staticData._consoleResult || {}).screenshot_b64;\nlet messageContent;\nif (screenshot) {\n  messageContent = [\n    { type: 'image_url', image_url: { url: 'data:image/png;base64,' + screenshot } },\n    { type: 'text', text: 'APP SCREENSHOT (current state of the running application):\\n\\n' + prompt }\n  ];\n} else {\n  messageContent = prompt;\n}\n\nreturn [{ json: {\n  model: $env.REVIEWER_MODEL || $env.AGENT_MODEL || 'qwen/qwen3.5-9b',\n  messages: [\n      { role: 'user', content: messageContent },\n      { role: 'assistant', content: '{' }\n    ],\n  temperature: 0.7, top_p: 0.8, top_k: 20, presence_penalty: 1.5, max_tokens: parseInt($env.REVIEWER_MAX_TOKENS) || 131072\n} }];"
      },
      "id": "code-review-build",
      "name": "Code Review: Build",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4900,
        400
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.LM_STUDIO_URL || 'http://10.0.0.100:1234/v1/chat/completions' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($env.LLM_API_KEY || '') }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json) }}",
        "options": {
          "timeout": 600000
        }
      },
      "id": "code-review-llm",
      "name": "Code Review: Call LM Studio",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5100,
        400
      ]
    },
    {
      "parameters": {
        "jsCode": "const msg = $json.choices?.[0]?.message || {};\nconst rawContent = msg.content || '';\nconst rawReasoning = msg.reasoning_content || '';\n\n// Check content first for JSON, then reasoning\nconst raw = (rawContent && rawContent.includes('code_quality')) ? rawContent : rawReasoning;\n\nlet content = raw.replace(/<think>[\\s\\S]*?<\\/think>/g, '').trim();\n// Prepend '{' if missing \u2014 we use assistant prefill to force JSON output\nif (content && !content.startsWith('{')) content = '{' + content;\nif (content.includes('</think>')) content = content.split('</think>').pop().trim();\ncontent = content.replace(/^```(?:json)?\\n?/, '').replace(/\\n?```$/, '').trim();\n\nconst jsonMatch = content.match(/\\{[\\s\\S]*\\}/);\nlet review;\nlet jsonStr = jsonMatch ? jsonMatch[0] : content;\ntry {\n  review = JSON.parse(jsonStr);\n} catch {\n  // Try fixing common JSON issues: unescaped quotes in string values\n  try {\n    // Replace unescaped quotes inside strings by finding patterns like \"value with \"quotes\" inside\"\n    const fixed = jsonStr.replace(/: \"([^\"]*?)\"/g, (match, p1) => {\n      // Only fix if the match isn't a proper JSON value ending\n      return match;\n    });\n    review = JSON.parse(fixed);\n  } catch {\n    // Last resort: extract key fields with regex\n    const qMatch = jsonStr.match(/\"code_quality\"\\s*:\\s*(\\d+)/);\n    const issuesMatch = jsonStr.match(/\"issues\"\\s*:\\s*\\[([\\s\\S]*?)\\]\\s*\\}/);\n    const sugMatch = jsonStr.match(/\"suggestions\"\\s*:\\s*\\[([\\s\\S]*?)\\]\\s*,/);\n    review = {\n      code_quality: qMatch ? parseInt(qMatch[1]) : 0,\n      issues: [],\n      suggestions: []\n    };\n    // Try to extract individual issues\n    if (issuesMatch) {\n      const issueBlocks = issuesMatch[1].match(/\\{[^{}]*\\}/g) || [];\n      for (const block of issueBlocks) {\n        try {\n          review.issues.push(JSON.parse(block));\n        } catch {\n          const file = block.match(/\"file\"\\s*:\\s*\"([^\"]+)\"/);\n          const severity = block.match(/\"severity\"\\s*:\\s*\"([^\"]+)\"/);\n          const issue = block.match(/\"issue\"\\s*:\\s*\"([^\"]+)\"/);\n          if (file && issue) {\n            review.issues.push({ file: file[1], severity: severity?.[1] || 'high', issue: issue[1], problem: '' });\n          }\n        }\n      }\n    }\n    if (sugMatch) {\n      const suggestions = sugMatch[1].match(/\"([^\"]{10,})\"/g) || [];\n      review.suggestions = suggestions.map(s => s.replace(/^\"|\"$/g, ''));\n    }\n  }\n}\n\n// Normalize field names \u2014 LLM sometimes uses different names\nif (!review.code_quality && review.code_quality !== 0) {\n  review.code_quality = review.quality || review.score || review.overall_quality || review.codeQuality || 0;\n}\nif (!review.issues) {\n  review.issues = review.fixes || review.bugs || review.problems || review.findings || [];\n}\n\nconst issues = review.issues || [];\nconst criticalCount = issues.filter(i => i.severity === 'critical' || i.severity === 'high').length;\nconst projectId = $('Extract Input').first().json.project_id || 'unknown';\n\nconst staticData = $getWorkflowStaticData('global');\nstaticData._reviewResult = review;\n\n// Callback\ntry {\n  const http = require('http');\n  const cbBody = JSON.stringify({\n    event: 'review_complete', project_id: projectId,\n    data: { quality: review.code_quality, fixes: criticalCount }\n  });\n  const req = http.request({ hostname: 'forge', port: 3500, path: '/api/status-callback', method: 'POST',\n    headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(cbBody) } });\n  req.on('error', () => {});\n  req.write(cbBody); req.end();\n} catch {}\n\nreturn [{ json: { review, critical_fix_count: criticalCount, fixes_needed: issues, suggestions: review.suggestions || [], project_id: projectId } }];"
      },
      "id": "code-review-parse",
      "name": "Code Review: Parse",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5300,
        400
      ]
    },
    {
      "parameters": {
        "jsCode": "const fixes = $json.fixes_needed || [];\nconst criticalCount = $json.critical_fix_count || 0;\nconst quality = ($json.review || {}).code_quality || 0;\n\n// Only fix if real critical bugs AND review produced valid output\nif (criticalCount > 0 && quality > 0) {\n  return [{ json: $json }];\n}\nreturn [];"
      },
      "id": "critical-gate",
      "name": "Critical Bug Gate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5500,
        350
      ]
    },
    {
      "parameters": {
        "jsCode": "const criticalCount = $json.critical_fix_count || 0;\nconst quality = ($json.review || {}).code_quality || 0;\n\nif (criticalCount > 0 && quality > 0) return [];\nreturn [{ json: $json }];"
      },
      "id": "nobug-gate",
      "name": "No Bug Gate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5500,
        500
      ]
    },
    {
      "parameters": {
        "jsCode": "const fixes = $json.fixes_needed || [];\nconst staticData = $getWorkflowStaticData('global');\nconst allFiles = staticData._allFileContents || [];\nconst projectId = $json.project_id;\n\n// Only fix files that exist\nconst existingPaths = new Set(allFiles.map(f => f.path));\nconst filesToFix = new Set(fixes.map(f => f.file).filter(f => existingPaths.has(f)));\n\nconst fixInstructions = fixes.map(f =>\n  'FILE: ' + f.file + '\\nSEVERITY: ' + f.severity + '\\nISSUE: ' + f.issue + '\\nFIX: ' + (f.problem || f.issue)\n).join('\\n\\n');\n\nconst existingFilesSection = allFiles\n  .filter(f => filesToFix.has(f.path))\n  .map(f => '### ' + f.path + '\\n```\\n' + (f.content || '') + '\\n```')\n  .join('\\n\\n');\n\nconst systemMsg = `You are a Senior Full-Stack Developer. Fix the specific issues listed below.\n\nENGINEERING PRINCIPLES:\n- Fix root causes, never work around errors or suppress them\n- Keep it simple \u2014 write the minimum code needed, no over-engineering\n- Don't add features, abstractions, or utilities that weren't asked for\n- Match existing patterns \u2014 if the project uses X style, follow X style\n- One component/function = one responsibility\n- Name things clearly \u2014 if you need a comment to explain a variable name, rename it\n- No dead code, no commented-out code, no console.logs left behind\n- Handle errors at system boundaries (user input, API calls), trust internal code\n- Prefer modifying existing files over creating new ones\n- DRY \u2014 but three similar lines are better than a premature abstraction\n\nFORMAT \u2014 for EACH file output EXACTLY:\n### path/to/file.ext\n\\`\\`\\`ext\n[complete file content]\n\\`\\`\\`\n\nRULES:\n- Use SEARCH/REPLACE blocks for existing files. Only use full content for new files\n- Only modify files listed in FILES TO FIX\n- No explanations, no prose \u2014 ONLY the file blocks with ### headers\n- Do NOT remove @tailwind directives\n- Do NOT remove existing exports`;\n// Include research docs and project memory so the fixer knows the APIs\nconst researchDocs = staticData._researchDocs || '';\nconst projectMemory = staticData._projectMemory || '';\n\nlet contextSection = '';\nif (researchDocs) contextSection += '\\n\\nLIBRARY DOCS (use these APIs, do NOT hallucinate methods):\\n' + researchDocs.substring(0, 8000);\nif (projectMemory) contextSection += '\\n\\nPROJECT CONTEXT:\\n' + projectMemory.substring(0, 2000);\n\nconst userMsg = 'Fix these issues:\\n\\n' + fixInstructions + '\\n\\nFILES TO FIX:\\n' + [...filesToFix].join('\\n') + '\\n\\nCURRENT FILES:\\n' + existingFilesSection + contextSection;\n\nreturn [{ json: {\n  model: $env.CODER_MODEL || 'qwen3.5-27b@q4_k_m',\n  messages: [\n    { role: 'system', content: systemMsg },\n    { role: 'user', content: userMsg }\n  ],\n  temperature: 0.6, top_p: 0.95, top_k: 20, max_tokens: parseInt($env.FIXER_MAX_TOKENS) || 32768,\n  _project_id: projectId\n} }];"
      },
      "id": "fix-build",
      "name": "Fix: Build",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5700,
        350
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.LM_STUDIO_URL || 'http://10.0.0.100:1234/v1/chat/completions' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($env.LLM_API_KEY || '') }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json) }}",
        "options": {
          "timeout": 600000
        }
      },
      "id": "fix-llm",
      "name": "Fix: Call LM Studio",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5900,
        350
      ]
    },
    {
      "parameters": {
        "jsCode": "const msg = $json.choices?.[0]?.message || {};\nconst rawContent = msg.content || '';\nconst rawReasoning = msg.reasoning_content || '';\n\nlet content = rawContent.replace(/<think>[\\s\\S]*?<\\/think>/g, '').trim();\nif (content.includes('</think>')) content = content.split('</think>').pop().trim();\nif (content.length < 50 && rawReasoning.length > 50) {\n  content = rawReasoning.replace(/<think>[\\s\\S]*?<\\/think>/g, '').trim();\n  if (content.includes('</think>')) content = content.split('</think>').pop().trim();\n}\n\nconst files = [];\nconst seen = new Set();\nconst projectId = $json._project_id || $('Extract Input').first().json.project_id || 'unknown';\nconst staticData = $getWorkflowStaticData('global');\nconst allFiles = staticData._allFileContents || [];\n\n// \u2500\u2500 1. Parse SEARCH/REPLACE blocks \u2500\u2500\nconst edits = [];\nconst editRe = /FILE:\\s*([\\w./-]+)\\s*\\n<<<<<<< SEARCH\\n([\\s\\S]*?)\\n=======\\n([\\s\\S]*?)\\n>>>>>>> REPLACE/g;\nlet em;\nwhile ((em = editRe.exec(content)) !== null) {\n  edits.push({ path: em[1].trim(), search: em[2], replace: em[3] });\n}\n\n// \u2500\u2500 2. Parse full file blocks \u2500\u2500\nconst fileRe = /###\\s+([\\w./-]+\\.(?:ts|tsx|js|jsx|json|css|html))\\s*\\n```[\\w]*\\n([\\s\\S]*?)```/g;\nlet fm;\nwhile ((fm = fileRe.exec(content)) !== null) {\n  const path = fm[1].trim();\n  if (!seen.has(path)) { seen.add(path); files.push({ path, content: fm[2] }); }\n}\n\n// \u2500\u2500 3. Apply edits \u2500\u2500\nconst editsByFile = {};\nfor (const e of edits) { if (!editsByFile[e.path]) editsByFile[e.path] = []; editsByFile[e.path].push(e); }\n\nfor (const [path, fileEdits] of Object.entries(editsByFile)) {\n  if (seen.has(path)) continue;\n  const existing = allFiles.find(f => f.path === path);\n  if (!existing) continue;\n  let fc = existing.content;\n  let applied = 0;\n  for (const edit of fileEdits) {\n    if (fc.includes(edit.search)) { fc = fc.replace(edit.search, edit.replace); applied++; }\n    else {\n      const st = edit.search.split('\\n').map(l => l.trim()).join('\\n');\n      const cl = fc.split('\\n');\n      for (let i = 0; i <= cl.length - edit.search.split('\\n').length; i++) {\n        if (cl.slice(i, i + edit.search.split('\\n').length).map(l => l.trim()).join('\\n') === st) {\n          cl.splice(i, edit.search.split('\\n').length, ...edit.replace.split('\\n'));\n          fc = cl.join('\\n'); applied++; break;\n        }\n      }\n    }\n  }\n  if (applied > 0) { seen.add(path); files.push({ path, content: fc }); }\n}\n\n// Filter to only existing files\nconst existingPaths = new Set(allFiles.map(f => f.path));\nconst validFiles = files.filter(f => existingPaths.has(f.path) || edits.some(e => e.path === f.path));\n\nif (validFiles.length === 0) {\n  return [{ json: { project_id: projectId, files: [], _skipWrite: true } }];\n}\nreturn [{ json: { project_id: projectId, files: validFiles } }];"
      },
      "id": "fix-parse",
      "name": "Fix: Parse",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        6100,
        350
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ ($env.FILE_API_URL || 'http://file-api:3456') + '/projects/' + $json.project_id + '/files-batch' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($env.FILE_API_TOKEN || '') }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ files: $json.files || [] }) }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "fix-write",
      "name": "Fix: Write Files",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        6300,
        350
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const staticData = $getWorkflowStaticData('global');\nconst http = require('http');\nconst projectId = $('Extract Input').first().json.project_id || 'unknown';\n\nconst buildResult = staticData._buildResult || {};\nconst reviewResult = staticData._reviewResult || {};\nconst p2Results = staticData.p2Results || [];\n\nlet reportText = '\\n' + '='.repeat(50) + '\\n';\nreportText += 'RUN | ' + projectId + ' | ' + new Date().toLocaleTimeString() + '\\n';\nreportText += '='.repeat(50) + '\\n';\n\n// Planner\nconst tasks = staticData._tasks || [];\nreportText += (tasks.length > 0 ? '+ ' : '- ') + 'PLANNER        ' + tasks.length + ' tasks\\n';\n\n// Research\nconst researchLen = (staticData._researchDocs || '').length;\nreportText += (researchLen > 100 ? '+ ' : 'o ') + 'RESEARCH       ' + (researchLen/1000).toFixed(1) + 'K chars\\n';\n\n// Coder\nreportText += (p2Results.length > 0 ? '+ ' : '- ') + 'CODER          ' + p2Results.map(r => r.task_id + ': ' + (r.files_written || []).length + ' files').join(', ') + '\\n';\n\n// Build\nreportText += (buildResult.success ? '+ ' : 'x ') + 'BUILD          ' + (buildResult.success ? 'Passed' : (buildResult.error || 'Failed').substring(0, 80)) + '\\n';\n\n// Console Check\nconst consoleResult = staticData._consoleResult || {};\nconst consoleErrors = (consoleResult.errors || []).length;\nconst consolePageError = consoleResult.page_error ? 'PAGE CRASH' : '';\nreportText += (consoleErrors === 0 && !consolePageError ? '+ ' : 'x ') + 'CONSOLE CHECK  ' +\n  (consolePageError || (consoleErrors === 0 ? 'Clean' : consoleErrors + ' errors')) + '\\n';\n\n// Audits\nconst audits = (staticData._consoleResult || {}).audits || {};\nconst vis = audits.visibility || { total: {} };\nconst links = audits.links || {};\nconst images = audits.images || {};\nconst contrast = audits.contrast || {};\nconst interactive = audits.interactive || {};\nconst content = audits.content || {};\n\nif (vis.total.hidden > 0 || (links.broken || []).length > 0 || (images.broken || []).length > 0) {\n  reportText += (vis.total.hidden > 3 ? 'x ' : '+ ') + 'AUDITS         ';\n  const parts = [];\n  if (vis.total.hidden > 0) parts.push(vis.total.hidden + '/' + vis.total.sections + ' invisible');\n  if ((links.broken || []).length > 0) parts.push((links.broken || []).length + ' broken links');\n  if ((images.broken || []).length > 0) parts.push((images.broken || []).length + ' broken images');\n  if ((contrast.failures || []).length > 0) parts.push((contrast.failures || []).length + ' contrast issues');\n  if ((interactive.blocked || []).length > 0) parts.push((interactive.blocked || []).length + ' blocked elements');\n  if (content.coveragePercent !== undefined) parts.push(content.coveragePercent + '% content coverage');\n  reportText += parts.join(', ') + '\\n';\n}\n\n// Code Review\nconst codeQuality = reviewResult.code_quality || 0;\nconst issueCount = (reviewResult.issues || []).length;\nreportText += (codeQuality > 0 ? '+ ' : 'x ') + 'CODE REVIEW    quality=' + codeQuality + ', issues=' + issueCount + '\\n';\n\n// Issues\nconst issues = [];\nif (!buildResult.success) issues.push('Build failed');\nif (codeQuality === 0 && issueCount === 0) issues.push('Code review produced no valid JSON');\n\nif (issues.length > 0) {\n  reportText += '-'.repeat(50) + '\\n';\n  reportText += 'ISSUES:\\n';\n  for (const issue of issues) reportText += '  ! ' + issue + '\\n';\n}\nreportText += '='.repeat(50) + '\\n';\n\n// Build structured report data\nconst reportData = {\n  report: reportText,\n  project: projectId,\n  timestamp: new Date().toISOString(),\n  tasks: p2Results.length,\n  files: p2Results.reduce((acc, r) => acc + (r.files_written || []).length, 0),\n  research: Math.round(researchLen / 1000 * 10) / 10,\n  build: buildResult.success ? 'passed' : 'failed',\n  buildError: buildResult.success ? null : (buildResult.error || '').substring(0, 100),\n  console: consoleErrors === 0 && !consolePageError ? 'clean' : (consolePageError || consoleErrors + ' errors'),\n  quality: codeQuality,\n  issues: issueCount,\n  audits: {\n    invisible: (vis.total || {}).hidden || 0,\n    totalElements: (vis.total || {}).sections || 0,\n    brokenLinks: (links.broken || []).length,\n    brokenImages: (images.broken || []).length,\n    contrastIssues: (contrast.failures || []).length,\n    contentCoverage: content.coveragePercent || null\n  },\n  git: null // will be populated by Git Auto-Commit node\n};\n\n// Callback\ntry {\n  const cbBody = JSON.stringify({ event: 'pipeline_report', project_id: projectId, data: reportData });\n  const req = http.request({ hostname: 'forge', port: 3500, path: '/api/status-callback', method: 'POST',\n    headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(cbBody) } });\n  req.on('error', () => {});\n  req.write(cbBody); req.end();\n} catch {}\n\nstaticData._pipelineReport = reportText;\nreturn [{ json: { _report: reportText, project_id: projectId } }];"
      },
      "id": "pipeline-report",
      "name": "Pipeline Report",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        6500,
        400
      ]
    },
    {
      "parameters": {
        "jsCode": "const http = require('http');\nconst staticData = $getWorkflowStaticData('global');\nconst projectId = $('Extract Input').first().json.project_id || 'unknown';\nconst p2Results = staticData.p2Results || [];\nconst buildResult = staticData._buildResult || {};\nconst reviewResult = staticData._reviewResult || {};\n\nconst filesWritten = p2Results.flatMap(r => r.files_written || []);\n\ntry {\n  const body = JSON.stringify({\n    event: 'pipeline_complete', project_id: projectId,\n    data: {\n      tasks_completed: p2Results.length,\n      files_written: filesWritten,\n      build_passed: buildResult.success,\n      code_quality: reviewResult.code_quality || 0,\n      project_id: projectId,\n      suggestions: (reviewResult.suggestions || []).map(s => typeof s === 'string' ? { preview: s, detail: s } : s),\n      report: staticData._pipelineReport || ''\n    }\n  });\n  const req = http.request({ hostname: 'forge', port: 3500, path: '/api/status-callback', method: 'POST',\n    headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } });\n  req.on('error', () => {});\n  req.write(body); req.end();\n} catch {}\n\nreturn [{ json: { status: 'completed', project_id: projectId } }];"
      },
      "id": "cb-complete",
      "name": "CB: Pipeline Complete",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        6700,
        400
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const http = require('http');\ntry {\n  const body = JSON.stringify({ instance_id: $env.CODER_MODEL || 'qwen3.5-27b@q4_k_m' });\n  const req = http.request({\n    hostname: '10.0.0.100', port: 1234, method: 'POST', path: '/api/v1/models/unload',\n    headers: { 'Authorization': 'Bearer ' + ($env.LLM_API_KEY || ''), 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },\n    timeout: 30000\n  }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => {}); });\n  req.on('error', () => {});\n  req.write(body); req.end();\n} catch {}\nreturn [{ json: $json }];"
      },
      "id": "unload-model",
      "name": "Unload Model",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        6900,
        400
      ]
    },
    {
      "parameters": {
        "jsCode": "const http = require('http');\nconst LM_KEY = $env.LLM_API_KEY || '';\n\n// Get all loaded models and unload them\nconst models = await new Promise((resolve) => {\n  const req = http.request({\n    hostname: '10.0.0.100', port: 1234, method: 'GET', path: '/api/v1/models',\n    headers: { 'Authorization': 'Bearer ' + LM_KEY },\n    timeout: 10000\n  }, res => {\n    let data = '';\n    res.on('data', c => data += c);\n    res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ models: [] }); } });\n  });\n  req.on('error', () => resolve({ models: [] }));\n  req.end();\n});\n\nfor (const m of (models.models || [])) {\n  for (const inst of (m.loaded_instances || [])) {\n    try {\n      const body = JSON.stringify({ instance_id: inst.id || m.key });\n      await new Promise((resolve) => {\n        const req = http.request({\n          hostname: '10.0.0.100', port: 1234, method: 'POST', path: '/api/v1/models/unload',\n          headers: { 'Authorization': 'Bearer ' + LM_KEY, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },\n          timeout: 15000\n        }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); });\n        req.on('error', () => resolve(''));\n        req.write(body); req.end();\n      });\n    } catch {}\n  }\n}\n\nreturn [{ json: $json }];"
      },
      "id": "startup-unload",
      "name": "Startup: Unload All",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1000,
        100
      ]
    },
    {
      "parameters": {
        "jsCode": "const http = require('http');\nconst staticData = $getWorkflowStaticData('global');\nconst projectId = $('Extract Input').first().json.project_id || 'unknown';\nconst currentMessage = $('Extract Input').first().json.goal || $('Extract Input').first().json.message || '';\nconst msgLower = currentMessage.toLowerCase();\nconst requestSummary = currentMessage\n  .replace(/\\n\\s+at\\s+.+/g, '')\n  .replace(/chunk-[A-Z0-9]+\\.js[^\\n]*/g, '')\n  .replace(/\\n{2,}/g, '\\n')\n  .trim()\n  .substring(0, 120);\nconst existingMemory = staticData._projectMemory || '';\n\n// Gather run data\nconst p2Results = staticData.p2Results || [];\nconst buildResult = staticData._buildResult || {};\nconst reviewResult = staticData._reviewResult || {};\nconst tasks = staticData.allTasks || [];\nconst filesWritten = [...new Set(p2Results.flatMap(r => r.files_written || []))];\nconst allFiles = staticData._allFileContents || [];\n\n// Parse existing memory into sections\nconst sections = {};\nif (existingMemory) {\n  const positions = [];\n  const re = /^## (.+)$/gm;\n  let m;\n  while ((m = re.exec(existingMemory)) !== null) {\n    positions.push({ name: m[1], start: m.index });\n  }\n  for (let i = 0; i < positions.length; i++) {\n    const end = i + 1 < positions.length ? positions[i + 1].start : existingMemory.length;\n    sections[positions[i].name] = existingMemory.substring(positions[i].start, end).trim();\n  }\n}\n\n// \u2500\u2500 ITERATION COUNT \u2500\u2500\nconst existingHistory = sections['Iteration History'] || '';\n\nconst existingRuns = existingHistory.split(/(?=### Iteration )/).filter(r => r.trim().startsWith('### Iteration'));\nconst iterNum = existingRuns.length + 1;\n\n\n// \u2500\u2500 CLASSIFY this run: initial build, bug fix, feature add, or refactor \u2500\u2500\n\nconst existingGoalRaw = (sections['Goal'] || '').replace(/^## Goal\\n?/, '').trim();\nconst existingGoal = existingGoalRaw;\nconst isInitial = iterNum === 1 || (!existingGoal && filesWritten.length > 5);\nlet runType = 'Update';\nif (isInitial) runType = 'Initial Build';\nelse if (msgLower.match(/fix|error|bug|crash|broken|undefined|not defined|not working/)) runType = 'Bug Fix';\nelse if (msgLower.match(/add|new|feature|implement|create/)) runType = 'Feature';\nelse if (msgLower.match(/refactor|clean|reorganize|restructure/)) runType = 'Refactor';\nelse if (msgLower.match(/style|design|layout|color|theme|css/)) runType = 'Styling';\n\n// \u2500\u2500 GOAL: evolves over time \u2014 original anchors, refinements accumulate \u2500\u2500\nlet projectGoal;\nif (!existingGoalRaw) {\n  // First run \u2014 current message IS the goal\n  projectGoal = currentMessage.substring(0, 300);\n} else {\n  // Follow-up run \u2014 preserve original goal, append meaningful refinements\n  // Split existing into base goal and refinements\n  const refinementMarker = '\\n\\n**Refinements:**';\n  const [baseGoal, existingRefinements] = existingGoalRaw.split(refinementMarker);\n  const priorRefinements = existingRefinements ? existingRefinements.trim().split('\\n').filter(l => l.startsWith('- ')) : [];\n  \n  // Only add refinement for feature/styling requests, not pure bug fixes\n  const isFeatureOrStyle = msgLower.match(/add|new|feature|implement|create|change|make|style|design|layout|color|theme|bigger|smaller|move|redesign|update|improve/);\n  \n  if (isFeatureOrStyle && iterNum > 1) {\n    const refinement = '- ' + requestSummary.substring(0, 80) + ' (Iteration ' + iterNum + ')';\n    // Avoid duplicate refinements\n    if (!priorRefinements.some(r => r.substring(0, 40) === refinement.substring(0, 40))) {\n      priorRefinements.push(refinement);\n    }\n  }\n  \n  // Keep last 8 refinements\n  const recentRefinements = priorRefinements.slice(-8);\n  projectGoal = baseGoal + (recentRefinements.length > 0 ? refinementMarker + '\\n' + recentRefinements.join('\\n') : '');\n}\n\n// \u2500\u2500 SUMMARIZE the request (first 120 chars, strip stack traces) \u2500\u2500\n\n\n// \u2500\u2500 BUILD the iteration entry \u2500\u2500\nconst now = new Date().toISOString().split('T')[0] + ' ' + new Date().toISOString().split('T')[1].substring(0, 5);\nconst codeQuality = reviewResult.code_quality || 0;\nconst reviewIssues = reviewResult.issues || [];\n\nlet entry = '### Iteration ' + iterNum + ' \u2014 ' + runType + ' (' + now + ')\\n';\nentry += '**Request:** ' + requestSummary + '\\n';\n\n// What was done\nconst taskLines = tasks.map(t => (t.task_id || '?') + ': ' + ((t.description || '').substring(0, 60))).join('; ');\nif (taskLines) entry += '**Tasks:** ' + taskLines + '\\n';\nentry += '**Files:** ' + (filesWritten.length > 0 ? filesWritten.join(', ') : 'none') + '\\n';\n\n// Result\nconst buildStr = buildResult.success ? 'PASSED' : 'FAILED \u2014 ' + (buildResult.error || '').substring(0, 60);\nentry += '**Result:** Build ' + buildStr;\nif (codeQuality > 0) entry += ', Quality ' + codeQuality + '/100';\nentry += '\\n';\n\n// Unresolved issues from this run\nif (reviewIssues.length > 0) {\n  const critical = reviewIssues.filter(i => (i.severity || '').match(/critical|high/i));\n  if (critical.length > 0) {\n    entry += '**Unresolved:** ' + critical.map(i => (i.file || '') + ': ' + (i.issue || '').substring(0, 60)).join('; ') + '\\n';\n  }\n}\nif (!buildResult.success) {\n  entry += '**Unresolved:** Build failure \u2014 ' + (buildResult.error || '').substring(0, 100) + '\\n';\n}\n\n// \u2500\u2500 KNOWN ISSUES: accumulate across runs, remove fixed ones \u2500\u2500\n// Start with existing issues\nconst existingIssuesRaw = (sections['Known Issues'] || '').replace(/^## Known Issues\\n?/, '').trim();\nconst existingIssueLines = existingIssuesRaw.split('\\n').filter(l => l.startsWith('- '));\n\n// New issues from this run's review\nconst newIssueLines = reviewIssues.map(i => '- [' + (i.severity || '?') + '] ' + (i.file || '?') + ': ' + (i.issue || ''));\n\n// Merge: keep existing issues not fixed in this run + add new ones\nconst fixedFiles = new Set(filesWritten);\nconst keptIssues = existingIssueLines.filter(line => {\n  // If the file mentioned in this issue was modified this run, consider it potentially fixed\n  const fileMatch = line.match(/\\] ([^\\s:]+)/);\n  return !fileMatch || !fixedFiles.has(fileMatch[1]);\n});\nconst allIssueSet = new Set([...keptIssues, ...newIssueLines]);\nlet knownIssues = [...allIssueSet].slice(0, 10).join('\\n');\nif (!knownIssues || knownIssues === '') knownIssues = 'None \u2014 last run was clean.';\n\n// \u2500\u2500 ARCHITECTURE: detect once, then preserve \u2500\u2500\nlet archSection = sections['Architecture'] || '';\nif (!archSection) {\n  const pkgFile = allFiles.find(f => f.path === 'package.json');\n  let framework = 'unknown';\n  let deps = [];\n  if (pkgFile) {\n    try {\n      const pkg = JSON.parse(pkgFile.content);\n      const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };\n      if (allDeps.next) framework = 'Next.js';\n      else if (allDeps.vite || allDeps['@vitejs/plugin-react']) framework = 'React + Vite';\n      else if (allDeps.express) framework = 'Express';\n      else if (allDeps.react) framework = 'React';\n      deps = Object.keys(allDeps);\n    } catch {}\n  }\n  archSection = '## Architecture\\n- Framework: ' + framework + '\\n- Key deps: ' + deps.slice(0, 8).join(', ') + '\\n- File count: ' + allFiles.length;\n} else {\n  // Update file count\n  archSection = archSection.replace(/File count: \\d+/, 'File count: ' + allFiles.length);\n}\n\n// \u2500\u2500 ASSETS \u2500\u2500\nconst assets = allFiles\n  .filter(f => f.path.match(/\\.(png|jpg|jpeg|svg|gif|webp)$/i))\n  .map(f => '- ' + f.path).join('\\n');\n\n// \u2500\u2500 DECISIONS: preserve user-added section \u2500\u2500\nconst decisionsSection = sections['Decisions'] || '';\n\n// \u2500\u2500 KEEP last 5 iterations \u2500\u2500\nconst recentRuns = [...existingRuns.slice(-4), entry];\n\n// \u2500\u2500 ASSEMBLE \u2500\u2500\nlet mem = '# Project Memory\\n\\n';\nmem += '## Goal\\n' + projectGoal + '\\n\\n';\nmem += archSection + '\\n\\n';\nif (decisionsSection) mem += decisionsSection + '\\n\\n';\nmem += '## Known Issues\\n' + knownIssues + '\\n\\n';\nmem += '## Iteration History\\n' + recentRuns.join('\\n') + '\\n';\nif (assets) mem += '\\n## Assets\\n' + assets + '\\n';\n\n// Cap at 5K (more room for richer entries)\nif (mem.length > 8000) mem = mem.substring(0, 7900) + '\\n\\n[truncated]\\n';\n\n// Write via file-api\nconst FILE_API = ($env.FILE_API_URL || 'http://file-api:3456').replace('http://', '');\nconst [apiHost, apiPort] = FILE_API.split(':');\nconst body = JSON.stringify({ files: [{ path: 'memory.md', content: mem }] });\nawait new Promise((resolve) => {\n  const req = http.request({\n    hostname: apiHost, port: parseInt(apiPort) || 3456,\n    path: '/projects/' + projectId + '/files-batch',\n    method: 'POST',\n    headers: {\n      'Authorization': 'Bearer ' + ($env.FILE_API_TOKEN || ''),\n      'Content-Type': 'application/json',\n      'Content-Length': Buffer.byteLength(body)\n    },\n    timeout: 10000\n  }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); });\n  req.on('error', () => resolve(''));\n  req.on('timeout', () => { req.destroy(); resolve(''); });\n  req.write(body); req.end();\n});\n\nreturn [{ json: $json }];"
      },
      "id": "write-memory",
      "name": "Write Project Memory",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        6700,
        400
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const http = require('http');\nconst staticData = $getWorkflowStaticData('global');\nconst projectId = $('Extract Input').first().json.project_id || 'unknown';\n\nlet consoleResult = { success: false, errors: [], warnings: [] };\ntry {\n  consoleResult = await new Promise((resolve) => {\n    const body = '{}';\n    const req = http.request({\n      hostname: 'file-api', port: 3456, method: 'POST',\n      path: '/projects/' + projectId + '/console-check',\n      headers: {\n        'Authorization': 'Bearer ' + ($env.FILE_API_TOKEN || ''),\n        'Content-Type': 'application/json',\n        'Content-Length': Buffer.byteLength(body)\n      },\n      timeout: 60000\n    }, res => {\n      let data = '';\n      res.on('data', c => data += c);\n      res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ success: false, error: data }); } });\n    });\n    req.on('error', e => resolve({ success: false, error: e.message, errors: [], warnings: [] }));\n    req.setTimeout(60000, () => { req.destroy(); resolve({ success: false, error: 'Console check timed out', errors: [], warnings: [] }); });\n    req.write(body);\n    req.end();\n  });\n} catch(e) {\n  consoleResult = { success: false, error: e.message, errors: [], warnings: [] };\n}\n\nstaticData._consoleResult = consoleResult;\n\n// Callback to Forge\ntry {\n  const audits = consoleResult.audits || {};\n  const errCount = (consoleResult.errors || []).length;\n  const warnCount = (consoleResult.warnings || []).length;\n  const cbBody = JSON.stringify({\n    event: consoleResult.page_error ? 'console_check_failed' : (errCount > 0 ? 'console_check_failed' : 'console_check_passed'),\n    project_id: projectId,\n    data: { errors: errCount, warnings: warnCount, page_error: consoleResult.page_error || null, audits: { visibility: (audits.visibility || {}).total, links: (audits.links || {}).broken?.length || 0, images: (audits.images || {}).broken?.length || 0, contrast: (audits.contrast || {}).failures?.length || 0, content_coverage: (audits.content || {}).coveragePercent } }\n  });\n  const cbReq = http.request({\n    hostname: 'forge', port: 3500, path: '/api/status-callback', method: 'POST',\n    headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(cbBody) }\n  });\n  cbReq.on('error', () => {});\n  cbReq.write(cbBody); cbReq.end();\n} catch {}\n\nreturn [{ json: $json }];"
      },
      "id": "console-check",
      "name": "Console Check",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4500,
        400
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const http = require('http');\nconst LM_KEY = $env.LLM_API_KEY || '';\nconst HOST = '10.0.0.100';\n\n// Unload all models\nconst models = await new Promise((resolve) => {\n  const req = http.request({\n    hostname: HOST, port: 1234, method: 'GET', path: '/api/v1/models',\n    headers: { 'Authorization': 'Bearer ' + LM_KEY }, timeout: 10000\n  }, res => {\n    let data = ''; res.on('data', c => data += c);\n    res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ models: [] }); } });\n  });\n  req.on('error', () => resolve({ models: [] }));\n  req.end();\n});\n\nfor (const m of (models.models || [])) {\n  for (const inst of (m.loaded_instances || [])) {\n    try {\n      const body = JSON.stringify({ instance_id: inst.id || m.key });\n      await new Promise((resolve) => {\n        const req = http.request({\n          hostname: HOST, port: 1234, method: 'POST', path: '/api/v1/models/unload',\n          headers: { 'Authorization': 'Bearer ' + LM_KEY, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },\n          timeout: 15000\n        }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); });\n        req.on('error', () => resolve(''));\n        req.write(body); req.end();\n      });\n    } catch {}\n  }\n}\n\n// Load 9B for review\nconst loadBody = JSON.stringify({ model: $env.AGENT_MODEL || 'qwen/qwen3.5-9b', context_length: parseInt($env.REVIEWER_CTX) || 65536 });\nawait new Promise((resolve) => {\n  const req = http.request({\n    hostname: HOST, port: 1234, method: 'POST', path: '/api/v1/models/load',\n    headers: { 'Authorization': 'Bearer ' + LM_KEY, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(loadBody) },\n    timeout: 60000\n  }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); });\n  req.on('error', () => resolve(''));\n  req.write(loadBody); req.end();\n});\n\nreturn [{ json: $json }];"
      },
      "id": "swap-to-9b",
      "name": "Swap: Load 9B",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4700,
        400
      ]
    },
    {
      "parameters": {
        "jsCode": "const http = require('http');\nconst LM_KEY = $env.LLM_API_KEY || '';\nconst HOST = '10.0.0.100';\n\n// Unload 9B\nconst models = await new Promise((resolve) => {\n  const req = http.request({\n    hostname: HOST, port: 1234, method: 'GET', path: '/api/v1/models',\n    headers: { 'Authorization': 'Bearer ' + LM_KEY }, timeout: 10000\n  }, res => {\n    let data = ''; res.on('data', c => data += c);\n    res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ models: [] }); } });\n  });\n  req.on('error', () => resolve({ models: [] }));\n  req.end();\n});\n\nfor (const m of (models.models || [])) {\n  for (const inst of (m.loaded_instances || [])) {\n    try {\n      const body = JSON.stringify({ instance_id: inst.id || m.key });\n      await new Promise((resolve) => {\n        const req = http.request({\n          hostname: HOST, port: 1234, method: 'POST', path: '/api/v1/models/unload',\n          headers: { 'Authorization': 'Bearer ' + LM_KEY, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },\n          timeout: 15000\n        }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); });\n        req.on('error', () => resolve(''));\n        req.write(body); req.end();\n      });\n    } catch {}\n  }\n}\n\n// Load 27B for fixer\nconst loadBody = JSON.stringify({ model: $env.CODER_MODEL || 'qwen3.5-27b@q4_k_m', context_length: parseInt($env.CODER_CTX) || 65536 });\nawait new Promise((resolve) => {\n  const req = http.request({\n    hostname: HOST, port: 1234, method: 'POST', path: '/api/v1/models/load',\n    headers: { 'Authorization': 'Bearer ' + LM_KEY, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(loadBody) },\n    timeout: 120000\n  }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); });\n  req.on('error', () => resolve(''));\n  req.write(loadBody); req.end();\n});\n\nreturn [{ json: $json }];"
      },
      "id": "swap-to-27b",
      "name": "Swap: Load 27B",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5700,
        350
      ]
    },
    {
      "parameters": {
        "jsCode": "const http = require('http');\nconst projectId = $('Extract Input').first().json.project_id || 'unknown';\nconst userMessage = ($('Extract Input').first().json.message || '').substring(0, 80);\nconst staticData = $getWorkflowStaticData('global');\nconst buildPassed = (staticData._buildResult || {}).success;\nconst quality = ((staticData._reviewResult || {}).code_quality) || '?';\n\nconst commitMsg = 'Pipeline: ' + (buildPassed ? 'PASS' : 'FAIL') + ' q=' + quality + ' \u2014 ' + userMessage;\n\nconst body = JSON.stringify({ message: commitMsg });\nconst result = await new Promise((resolve) => {\n  const req = http.request({\n    hostname: 'file-api', port: 3456, method: 'POST',\n    path: '/projects/' + projectId + '/git-commit',\n    headers: {\n      'Authorization': 'Bearer ' + ($env.FILE_API_TOKEN || ''),\n      'Content-Type': 'application/json',\n      'Content-Length': Buffer.byteLength(body)\n    },\n    timeout: 15000\n  }, res => {\n    let data = '';\n    res.on('data', c => data += c);\n    res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ success: false }); } });\n  });\n  req.on('error', () => resolve({ success: false }));\n  req.write(body); req.end();\n});\n\nreturn [{ json: { ...$json, git_commit: result } }];"
      },
      "id": "git-commit",
      "name": "Git: Auto-Commit",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        6200,
        100
      ]
    },
    {
      "parameters": {
        "jsCode": "const http = require('http');\nconst staticData = $getWorkflowStaticData('global');\nconst projectId = staticData._currentProjectId || $('Extract Input').first().json.project_id || 'unknown';\n\nconst result = await new Promise((resolve) => {\n  const req = http.request({\n    hostname: 'file-api', port: 3456, method: 'POST',\n    path: '/projects/' + projectId + '/typecheck',\n    headers: {\n      'Authorization': 'Bearer ' + ($env.FILE_API_TOKEN || ''),\n      'Content-Type': 'application/json',\n      'Content-Length': 2\n    },\n    timeout: 30000\n  }, res => {\n    let data = '';\n    res.on('data', c => data += c);\n    res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ success: true, errors: [] }); } });\n  });\n  req.on('error', () => resolve({ success: true, errors: [] }));\n  req.setTimeout(30000, () => { req.destroy(); resolve({ success: true, errors: [] }); });\n  req.write('{}'); req.end();\n});\n\nif (!staticData._tsErrors) staticData._tsErrors = [];\nconst errors = result.errors || [];\nif (errors.length > 0) {\n  staticData._tsErrors.push(...errors.slice(0, 10));\n}\n\n// Track retry count\nif (!staticData._tsFixAttempt) staticData._tsFixAttempt = 0;\n\nreturn [{ json: { \n  ...$json, \n  typecheck_passed: result.success || errors.length === 0,\n  ts_errors: errors,\n  ts_error_summary: errors.slice(0, 8).map(e => e.file + ':' + e.line + ' ' + e.message).join('\\n'),\n  _tsFixAttempt: staticData._tsFixAttempt\n} }];"
      },
      "id": "ts-check",
      "name": "TypeScript Check",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3600,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const passed = $json.typecheck_passed;\nconst attempt = $json._tsFixAttempt || 0;\nconst staticData = $getWorkflowStaticData('global');\n\n// Pass, or max retries reached (2 attempts max)\nif (passed || attempt >= 2) {\n  if (!passed) {\n    // Log that we gave up after 2 attempts\n    try {\n      const http = require('http');\n      const cbBody = JSON.stringify({\n        event: 'typecheck_warning', project_id: $('Extract Input').first().json.project_id || 'unknown',\n        data: { message: 'TypeScript errors remain after ' + attempt + ' fix attempts', errors: ($json.ts_errors || []).length }\n      });\n      const req = http.request({ hostname: 'forge', port: 3500, path: '/api/status-callback', method: 'POST',\n        headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(cbBody) } });\n      req.on('error', () => {}); req.write(cbBody); req.end();\n    } catch {}\n  }\n  staticData._tsFixAttempt = 0;\n  return [{ json: $json }];\n}\n\n// Fail \u2014 needs fix\nreturn [];"
      },
      "id": "ts-route-pass",
      "name": "TS: Pass Gate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3700,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const passed = $json.typecheck_passed;\nconst attempt = $json._tsFixAttempt || 0;\n\nif (!passed && attempt < 2) {\n  return [{ json: $json }];\n}\nreturn [];"
      },
      "id": "ts-route-fail",
      "name": "TS: Fix Gate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3700,
        500
      ]
    },
    {
      "parameters": {
        "jsCode": "const staticData = $getWorkflowStaticData('global');\nconst allFiles = staticData._allFileContents || [];\nconst errors = $json.ts_errors || [];\nconst projectId = staticData._currentProjectId || $('Extract Input').first().json.project_id || 'unknown';\n\nstaticData._tsFixAttempt = (staticData._tsFixAttempt || 0) + 1;\n\n// Get the files that have errors\nconst errorFiles = new Set(errors.map(e => e.file.replace(/^\\.\\//, '')));\nconst fileSections = allFiles\n  .filter(f => errorFiles.has(f.path))\n  .map(f => '### ' + f.path + '\\n```\\n' + (f.content || '') + '\\n```')\n  .join('\\n\\n');\n\nconst errorList = errors.map(e => e.file + ':' + e.line + ' \u2014 ' + e.code + ': ' + e.message).join('\\n');\n\nconst systemMsg = `You are a Senior Full-Stack Developer. Fix the TypeScript errors listed below.\n\nFORMAT \u2014 use SEARCH/REPLACE blocks for targeted edits:\nFILE: path/to/file.ext\n<<<<<<< SEARCH\n[exact lines to find]\n=======\n[replacement lines]\n>>>>>>> REPLACE\n\nRULES:\n- Fix ONLY the TypeScript errors listed \u2014 do not change anything else\n- Do NOT remove existing exports or imports that are still used\n- Do NOT remove @tailwind directives from CSS files`;\n\nconst userMsg = 'Fix these TypeScript errors:\\n\\n' + errorList + '\\n\\nFILES WITH ERRORS:\\n' + fileSections;\n\nreturn [{ json: {\n  model: $env.CODER_MODEL || 'qwen3.5-27b@q4_k_m',\n  messages: [\n    { role: 'system', content: systemMsg },\n    { role: 'user', content: userMsg }\n  ],\n  temperature: 0.3, top_p: 0.9, top_k: 20, max_tokens: parseInt($env.CODER_MAX_TOKENS) || 32768,\n  _project_id: projectId\n} }];"
      },
      "id": "ts-fix-build",
      "name": "TS Fix: Build",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3800,
        500
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.LM_STUDIO_URL || 'http://10.0.0.100:1234/v1/chat/completions' }}",
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($env.LLM_API_KEY || '') }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json) }}",
        "options": {
          "timeout": 300000
        }
      },
      "id": "ts-fix-call",
      "name": "TS Fix: Call LM Studio",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3900,
        500
      ]
    },
    {
      "parameters": {
        "jsCode": "const msg = $json.choices[0].message || {};\nlet raw = msg.content || '';\nconst reasoning = msg.reasoning_content || '';\n\nlet content = raw.replace(/<think>[\\s\\S]*?<\\/think>/g, '').trim();\nif (content.includes('</think>')) content = content.split('</think>').pop().trim();\nif (content.length < 50 && reasoning.length > 50) {\n  content = reasoning.replace(/<think>[\\s\\S]*?<\\/think>/g, '').trim();\n  if (content.includes('</think>')) content = content.split('</think>').pop().trim();\n}\n\nconst files = [];\nconst seen = new Set();\nconst staticData = $getWorkflowStaticData('global');\nconst allFiles = staticData._allFileContents || [];\nconst projectId = staticData._currentProjectId || $('Extract Input').first().json.project_id || 'unknown';\n\n// Parse SEARCH/REPLACE blocks\nconst edits = [];\nconst editRe = /FILE:\\s*([\\w.\\/-]+)\\s*\\n<<<<<<< SEARCH\\n([\\s\\S]*?)\\n=======\\n([\\s\\S]*?)\\n>>>>>>> REPLACE/g;\nlet em;\nwhile ((em = editRe.exec(content)) !== null) {\n  edits.push({ path: em[1].trim(), search: em[2], replace: em[3] });\n}\n\n// Parse full file blocks\nconst fileRe = /###\\s+([\\w.\\/-]+\\.(?:ts|tsx|js|jsx|json|css|html))\\s*\\n\\`\\`\\`[\\w]*\\n([\\s\\S]*?)\\`\\`\\`/g;\nlet fm;\nwhile ((fm = fileRe.exec(content)) !== null) {\n  if (!seen.has(fm[1].trim())) { seen.add(fm[1].trim()); files.push({ path: fm[1].trim(), content: fm[2] }); }\n}\n\n// Apply edits\nconst editsByFile = {};\nfor (const e of edits) { if (!editsByFile[e.path]) editsByFile[e.path] = []; editsByFile[e.path].push(e); }\nfor (const [p, fileEdits] of Object.entries(editsByFile)) {\n  if (seen.has(p)) continue;\n  const existing = allFiles.find(f => f.path === p);\n  if (!existing) continue;\n  let fc = existing.content;\n  let applied = 0;\n  for (const edit of fileEdits) {\n    if (fc.includes(edit.search)) { fc = fc.replace(edit.search, edit.replace); applied++; }\n  }\n  if (applied > 0) { seen.add(p); files.push({ path: p, content: fc }); }\n}\n\nif (files.length === 0) return [{ json: { project_id: projectId, files: [], _skipWrite: true } }];\nreturn [{ json: { project_id: projectId, files } }];"
      },
      "id": "ts-fix-parse",
      "name": "TS Fix: Parse",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4000,
        500
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ ($env.FILE_API_URL || 'http://file-api:3456') + '/projects/' + $json.project_id + '/files-batch' }}",
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($env.FILE_API_TOKEN || '') }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ files: $json.files || [] }) }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "ts-fix-write",
      "name": "TS Fix: Write",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4100,
        500
      ],
      "onError": "continueRegularOutput"
    }
  ],
  "connections": {
    "Webhook": {
      "main": [
        [
          {
            "node": "Extract Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Input": {
      "main": [
        [
          {
            "node": "CB: Pipeline Started",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Project Files": {
      "main": [
        [
          {
            "node": "Prepare Planner Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Planner Input": {
      "main": [
        [
          {
            "node": "Startup: Unload All",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Model": {
      "main": [
        [
          {
            "node": "Planner: Build Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Planner: Build Request": {
      "main": [
        [
          {
            "node": "Planner: Call LM Studio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Planner: Call LM Studio": {
      "main": [
        [
          {
            "node": "Planner: Parse Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Planner: Parse Response": {
      "main": [
        [
          {
            "node": "CB: Planning Complete",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Research: Fetch Docs": {
      "main": [
        [
          {
            "node": "Spread Tasks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Spread Tasks": {
      "main": [
        [
          {
            "node": "P2: Stash Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P2: Stash Context": {
      "main": [
        [
          {
            "node": "P2: Build Code Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P2: Build Code Input": {
      "main": [
        [
          {
            "node": "CW: Prepare Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CW: Prepare Message": {
      "main": [
        [
          {
            "node": "CW: Call LM Studio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CW: Call LM Studio": {
      "main": [
        [
          {
            "node": "CW: Parse Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CW: Parse Response": {
      "main": [
        [
          {
            "node": "P2: Prepare Write",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P2: Prepare Write": {
      "main": [
        [
          {
            "node": "P2: Write Files",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P2: Write Files": {
      "main": [
        [
          {
            "node": "TypeScript Check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P2: Store Result": {
      "main": [
        [
          {
            "node": "P2: Continue Gate",
            "type": "main",
            "index": 0
          },
          {
            "node": "P2: Exit Gate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P2: Continue Gate": {
      "main": [
        [
          {
            "node": "P2: Stash Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P2: Exit Gate": {
      "main": [
        [
          {
            "node": "Build Check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Check": {
      "main": [
        [
          {
            "node": "Build Route",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Route": {
      "main": [
        [
          {
            "node": "Auto-Fix Gate",
            "type": "main",
            "index": 0
          },
          {
            "node": "Build Pass Gate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Auto-Fix Gate": {
      "main": [
        [
          {
            "node": "Auto-Fix: Prepare",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Auto-Fix: Prepare": {
      "main": [
        [
          {
            "node": "Auto-Fix: Call LM Studio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Auto-Fix: Call LM Studio": {
      "main": [
        [
          {
            "node": "Auto-Fix: Parse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Auto-Fix: Parse": {
      "main": [
        [
          {
            "node": "Auto-Fix: Write Files",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Auto-Fix: Write Files": {
      "main": [
        [
          {
            "node": "Build Check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Pass Gate": {
      "main": [
        [
          {
            "node": "Console Check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code Review: Build": {
      "main": [
        [
          {
            "node": "Code Review: Call LM Studio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code Review: Call LM Studio": {
      "main": [
        [
          {
            "node": "Code Review: Parse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code Review: Parse": {
      "main": [
        [
          {
            "node": "Critical Bug Gate",
            "type": "main",
            "index": 0
          },
          {
            "node": "No Bug Gate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Critical Bug Gate": {
      "main": [
        [
          {
            "node": "Swap: Load 27B",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fix: Build": {
      "main": [
        [
          {
            "node": "Fix: Call LM Studio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fix: Call LM Studio": {
      "main": [
        [
          {
            "node": "Fix: Parse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fix: Parse": {
      "main": [
        [
          {
            "node": "Fix: Write Files",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fix: Write Files": {
      "main": [
        [
          {
            "node": "Pipeline Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "No Bug Gate": {
      "main": [
        [
          {
            "node": "Pipeline Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pipeline Report": {
      "main": [
        [
          {
            "node": "Git: Auto-Commit",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CB: Pipeline Complete": {
      "main": [
        [
          {
            "node": "Unload Model",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CB: Pipeline Started": {
      "main": [
        [
          {
            "node": "Fetch Project Files",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CB: Planning Complete": {
      "main": [
        [
          {
            "node": "Research: Fetch Docs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Startup: Unload All": {
      "main": [
        [
          {
            "node": "Load Model",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write Project Memory": {
      "main": [
        [
          {
            "node": "CB: Pipeline Complete",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Console Check": {
      "main": [
        [
          {
            "node": "Swap: Load 9B",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Swap: Load 9B": {
      "main": [
        [
          {
            "node": "Code Review: Build",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Swap: Load 27B": {
      "main": [
        [
          {
            "node": "Fix: Build",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Git: Auto-Commit": {
      "main": [
        [
          {
            "node": "Write Project Memory",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "TypeScript Check": {
      "main": [
        [
          {
            "node": "TS: Pass Gate",
            "type": "main",
            "index": 0
          },
          {
            "node": "TS: Fix Gate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "TS: Pass Gate": {
      "main": [
        [
          {
            "node": "P2: Store Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "TS: Fix Gate": {
      "main": [
        [
          {
            "node": "TS Fix: Build",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "TS Fix: Build": {
      "main": [
        [
          {
            "node": "TS Fix: Call LM Studio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "TS Fix: Call LM Studio": {
      "main": [
        [
          {
            "node": "TS Fix: Parse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "TS Fix: Parse": {
      "main": [
        [
          {
            "node": "TS Fix: Write",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "TS Fix: Write": {
      "main": [
        [
          {
            "node": "TypeScript Check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "saveExecutionProgress": true
  }
}