@assistkick/create 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/dist/src/scaffolder.d.ts +12 -1
  2. package/dist/src/scaffolder.js +40 -3
  3. package/dist/src/scaffolder.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/assistkick-product-system/package.json +1 -1
  6. package/templates/assistkick-product-system/packages/backend/package.json +1 -0
  7. package/templates/assistkick-product-system/packages/backend/src/mcp/permission_mcp_server.ts +196 -0
  8. package/templates/assistkick-product-system/packages/backend/src/routes/agents.ts +31 -7
  9. package/templates/assistkick-product-system/packages/backend/src/routes/auth.ts +15 -12
  10. package/templates/assistkick-product-system/packages/backend/src/routes/chat_files.test.ts +95 -0
  11. package/templates/assistkick-product-system/packages/backend/src/routes/chat_files.ts +97 -0
  12. package/templates/assistkick-product-system/packages/backend/src/routes/chat_permission.ts +94 -0
  13. package/templates/assistkick-product-system/packages/backend/src/routes/chat_sessions.ts +189 -0
  14. package/templates/assistkick-product-system/packages/backend/src/routes/chat_upload.test.ts +131 -0
  15. package/templates/assistkick-product-system/packages/backend/src/routes/chat_upload.ts +94 -0
  16. package/templates/assistkick-product-system/packages/backend/src/routes/files.test.ts +12 -3
  17. package/templates/assistkick-product-system/packages/backend/src/routes/files.ts +2 -2
  18. package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +390 -22
  19. package/templates/assistkick-product-system/packages/backend/src/routes/git_branches.test.ts +306 -0
  20. package/templates/assistkick-product-system/packages/backend/src/routes/git_connect.test.ts +133 -0
  21. package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +66 -9
  22. package/templates/assistkick-product-system/packages/backend/src/routes/preview.ts +204 -0
  23. package/templates/assistkick-product-system/packages/backend/src/routes/projects.test.ts +205 -0
  24. package/templates/assistkick-product-system/packages/backend/src/routes/projects.ts +37 -9
  25. package/templates/assistkick-product-system/packages/backend/src/routes/skills.test.ts +139 -0
  26. package/templates/assistkick-product-system/packages/backend/src/routes/skills.ts +95 -0
  27. package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +5 -4
  28. package/templates/assistkick-product-system/packages/backend/src/routes/users.ts +4 -4
  29. package/templates/assistkick-product-system/packages/backend/src/routes/video.ts +8 -8
  30. package/templates/assistkick-product-system/packages/backend/src/routes/workflow_groups.ts +5 -5
  31. package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +6 -6
  32. package/templates/assistkick-product-system/packages/backend/src/server.ts +107 -27
  33. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.test.ts +105 -203
  34. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.ts +76 -266
  35. package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.test.ts +427 -0
  36. package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts +345 -0
  37. package/templates/assistkick-product-system/packages/backend/src/services/chat_message_repository.test.ts +170 -0
  38. package/templates/assistkick-product-system/packages/backend/src/services/chat_message_repository.ts +106 -0
  39. package/templates/assistkick-product-system/packages/backend/src/services/chat_session_service.test.ts +217 -0
  40. package/templates/assistkick-product-system/packages/backend/src/services/chat_session_service.ts +188 -0
  41. package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.test.ts +1243 -0
  42. package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +894 -0
  43. package/templates/assistkick-product-system/packages/backend/src/services/coherence-review.ts +3 -3
  44. package/templates/assistkick-product-system/packages/backend/src/services/dev_command_detector.test.ts +85 -0
  45. package/templates/assistkick-product-system/packages/backend/src/services/dev_command_detector.ts +54 -0
  46. package/templates/assistkick-product-system/packages/backend/src/services/email_service.ts +13 -10
  47. package/templates/assistkick-product-system/packages/backend/src/services/init.ts +11 -3
  48. package/templates/assistkick-product-system/packages/backend/src/services/invitation_service.ts +1 -1
  49. package/templates/assistkick-product-system/packages/backend/src/services/password_reset_service.ts +1 -1
  50. package/templates/assistkick-product-system/packages/backend/src/services/permission_service.test.ts +243 -0
  51. package/templates/assistkick-product-system/packages/backend/src/services/permission_service.ts +259 -0
  52. package/templates/assistkick-product-system/packages/backend/src/services/preview_server_manager.test.ts +172 -0
  53. package/templates/assistkick-product-system/packages/backend/src/services/preview_server_manager.ts +225 -0
  54. package/templates/assistkick-product-system/packages/backend/src/services/project_service.test.ts +29 -0
  55. package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +17 -0
  56. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +255 -0
  57. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +300 -25
  58. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +44 -0
  59. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +62 -7
  60. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.test.ts +77 -6
  61. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +129 -8
  62. package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +2 -1
  63. package/templates/assistkick-product-system/packages/backend/src/services/title_generator_service.test.ts +45 -0
  64. package/templates/assistkick-product-system/packages/backend/src/services/title_generator_service.ts +157 -0
  65. package/templates/assistkick-product-system/packages/backend/src/services/tts_service.ts +4 -3
  66. package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.ts +3 -3
  67. package/templates/assistkick-product-system/packages/frontend/package.json +5 -0
  68. package/templates/assistkick-product-system/packages/frontend/src/App.tsx +2 -0
  69. package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +336 -5
  70. package/templates/assistkick-product-system/packages/frontend/src/components/AgentsView.tsx +192 -12
  71. package/templates/assistkick-product-system/packages/frontend/src/components/AttachmentPreviewList.tsx +98 -0
  72. package/templates/assistkick-product-system/packages/frontend/src/components/AutocompleteDropdown.tsx +65 -0
  73. package/templates/assistkick-product-system/packages/frontend/src/components/ChatAttachButton.tsx +56 -0
  74. package/templates/assistkick-product-system/packages/frontend/src/components/ChatDropZone.tsx +80 -0
  75. package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageBubble.tsx +155 -0
  76. package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageContent.tsx +182 -0
  77. package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageInput.tsx +233 -0
  78. package/templates/assistkick-product-system/packages/frontend/src/components/ChatSessionSidebar.tsx +218 -0
  79. package/templates/assistkick-product-system/packages/frontend/src/components/ChatStopButton.tsx +32 -0
  80. package/templates/assistkick-product-system/packages/frontend/src/components/ChatTodoSidebar.tsx +113 -0
  81. package/templates/assistkick-product-system/packages/frontend/src/components/ChatView.tsx +842 -0
  82. package/templates/assistkick-product-system/packages/frontend/src/components/CommitMessageModal.tsx +82 -0
  83. package/templates/assistkick-product-system/packages/frontend/src/components/DiagramOverlay.tsx +160 -0
  84. package/templates/assistkick-product-system/packages/frontend/src/components/EditorTabBar.tsx +5 -5
  85. package/templates/assistkick-product-system/packages/frontend/src/components/FileTree.tsx +9 -10
  86. package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeInlineInput.tsx +5 -5
  87. package/templates/assistkick-product-system/packages/frontend/src/components/FilesView.tsx +112 -41
  88. package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +2 -2
  89. package/templates/assistkick-product-system/packages/frontend/src/components/HighlightedText.tsx +87 -0
  90. package/templates/assistkick-product-system/packages/frontend/src/components/ImageLightbox.tsx +192 -0
  91. package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +2 -2
  92. package/templates/assistkick-product-system/packages/frontend/src/components/MentionPill.tsx +33 -0
  93. package/templates/assistkick-product-system/packages/frontend/src/components/MermaidBlock.tsx +148 -0
  94. package/templates/assistkick-product-system/packages/frontend/src/components/PermissionDialog.tsx +91 -0
  95. package/templates/assistkick-product-system/packages/frontend/src/components/PermissionModeSelector.tsx +229 -0
  96. package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +249 -83
  97. package/templates/assistkick-product-system/packages/frontend/src/components/QueuedMessageBubble.tsx +38 -0
  98. package/templates/assistkick-product-system/packages/frontend/src/components/SidePanel.tsx +212 -117
  99. package/templates/assistkick-product-system/packages/frontend/src/components/SystemPromptAccordion.tsx +48 -0
  100. package/templates/assistkick-product-system/packages/frontend/src/components/TaskIcon.tsx +11 -0
  101. package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +25 -9
  102. package/templates/assistkick-product-system/packages/frontend/src/components/ToolDiffView.tsx +114 -0
  103. package/templates/assistkick-product-system/packages/frontend/src/components/ToolResultCard.tsx +87 -0
  104. package/templates/assistkick-product-system/packages/frontend/src/components/ToolUseCard.tsx +149 -0
  105. package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +25 -8
  106. package/templates/assistkick-product-system/packages/frontend/src/components/UnifiedGitWidget.tsx +722 -0
  107. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GroupNode.tsx +2 -0
  108. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/NodePalette.tsx +2 -1
  109. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/ProgrammableNode.tsx +178 -0
  110. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +3 -0
  111. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +103 -9
  112. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +26 -2
  113. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +42 -1
  114. package/templates/assistkick-product-system/packages/frontend/src/hooks/useDocumentTitle.ts +11 -0
  115. package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +1 -0
  116. package/templates/assistkick-product-system/packages/frontend/src/hooks/use_chat_stream.ts +826 -0
  117. package/templates/assistkick-product-system/packages/frontend/src/hooks/use_file_tree_cache.ts +69 -0
  118. package/templates/assistkick-product-system/packages/frontend/src/hooks/use_mention_autocomplete.ts +284 -0
  119. package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.test.ts +183 -0
  120. package/templates/assistkick-product-system/packages/frontend/src/lib/attachment_manager.ts +150 -0
  121. package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.test.ts +305 -0
  122. package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.ts +113 -0
  123. package/templates/assistkick-product-system/packages/frontend/src/lib/context_usage_helpers.test.ts +157 -0
  124. package/templates/assistkick-product-system/packages/frontend/src/lib/context_usage_helpers.ts +95 -0
  125. package/templates/assistkick-product-system/packages/frontend/src/lib/mermaid_helpers.test.ts +65 -0
  126. package/templates/assistkick-product-system/packages/frontend/src/lib/mermaid_helpers.ts +110 -0
  127. package/templates/assistkick-product-system/packages/frontend/src/lib/message_queue.ts +66 -0
  128. package/templates/assistkick-product-system/packages/frontend/src/lib/tool_use_summary.test.ts +124 -0
  129. package/templates/assistkick-product-system/packages/frontend/src/lib/tool_use_summary.ts +112 -0
  130. package/templates/assistkick-product-system/packages/frontend/src/routes/AgentsRoute.tsx +2 -0
  131. package/templates/assistkick-product-system/packages/frontend/src/routes/ChatRoute.tsx +8 -0
  132. package/templates/assistkick-product-system/packages/frontend/src/routes/CoherenceRoute.tsx +2 -0
  133. package/templates/assistkick-product-system/packages/frontend/src/routes/DashboardLayout.tsx +0 -4
  134. package/templates/assistkick-product-system/packages/frontend/src/routes/DesignSystemRoute.tsx +2 -0
  135. package/templates/assistkick-product-system/packages/frontend/src/routes/FilesRoute.tsx +2 -0
  136. package/templates/assistkick-product-system/packages/frontend/src/routes/GraphRoute.tsx +2 -0
  137. package/templates/assistkick-product-system/packages/frontend/src/routes/KanbanRoute.tsx +2 -0
  138. package/templates/assistkick-product-system/packages/frontend/src/routes/TerminalRoute.tsx +2 -0
  139. package/templates/assistkick-product-system/packages/frontend/src/routes/UsersRoute.tsx +2 -0
  140. package/templates/assistkick-product-system/packages/frontend/src/routes/VideographyRoute.tsx +2 -0
  141. package/templates/assistkick-product-system/packages/frontend/src/routes/WorkflowsRoute.tsx +2 -0
  142. package/templates/assistkick-product-system/packages/frontend/src/routes/accept_invitation.tsx +2 -0
  143. package/templates/assistkick-product-system/packages/frontend/src/routes/forgot_password.tsx +2 -0
  144. package/templates/assistkick-product-system/packages/frontend/src/routes/login.tsx +2 -0
  145. package/templates/assistkick-product-system/packages/frontend/src/routes/register.tsx +2 -0
  146. package/templates/assistkick-product-system/packages/frontend/src/routes/reset_password.tsx +2 -0
  147. package/templates/assistkick-product-system/packages/frontend/src/stores/useAttachmentStore.ts +66 -0
  148. package/templates/assistkick-product-system/packages/frontend/src/stores/useChatSessionStore.ts +107 -0
  149. package/templates/assistkick-product-system/packages/frontend/src/stores/useMessageQueueStore.ts +110 -0
  150. package/templates/assistkick-product-system/packages/frontend/src/stores/usePreviewStore.ts +78 -0
  151. package/templates/assistkick-product-system/packages/frontend/src/stores/useProjectStore.ts +7 -0
  152. package/templates/assistkick-product-system/packages/frontend/src/stores/useSidePanelStore.ts +6 -1
  153. package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +30 -357
  154. package/templates/assistkick-product-system/packages/frontend/src/utils/parse_node_markdown.test.ts +115 -0
  155. package/templates/assistkick-product-system/packages/frontend/src/utils/parse_node_markdown.ts +91 -0
  156. package/templates/assistkick-product-system/packages/frontend/src/utils/preview_utils.test.ts +30 -0
  157. package/templates/assistkick-product-system/packages/frontend/src/utils/preview_utils.ts +3 -0
  158. package/templates/assistkick-product-system/packages/shared/db/migrations/0015_magenta_jazinda.sql +1 -0
  159. package/templates/assistkick-product-system/packages/shared/db/migrations/0016_giant_xorn.sql +1 -0
  160. package/templates/assistkick-product-system/packages/shared/db/migrations/0017_sloppy_mentor.sql +6 -0
  161. package/templates/assistkick-product-system/packages/shared/db/migrations/0018_vengeful_kabuki.sql +9 -0
  162. package/templates/assistkick-product-system/packages/shared/db/migrations/0019_careful_sentinels.sql +8 -0
  163. package/templates/assistkick-product-system/packages/shared/db/migrations/0020_clever_spot.sql +27 -0
  164. package/templates/assistkick-product-system/packages/shared/db/migrations/0021_graceful_hex.sql +1 -0
  165. package/templates/assistkick-product-system/packages/shared/db/migrations/0022_short_kingpin.sql +1 -0
  166. package/templates/assistkick-product-system/packages/shared/db/migrations/0023_ambiguous_sharon_carter.sql +1 -0
  167. package/templates/assistkick-product-system/packages/shared/db/migrations/0024_fat_unus.sql +1 -0
  168. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0015_snapshot.json +1552 -0
  169. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0016_snapshot.json +1560 -0
  170. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0017_snapshot.json +1598 -0
  171. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0018_snapshot.json +1657 -0
  172. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0019_snapshot.json +1709 -0
  173. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0020_snapshot.json +1733 -0
  174. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0021_snapshot.json +1740 -0
  175. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0022_snapshot.json +1755 -0
  176. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0023_snapshot.json +1762 -0
  177. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0024_snapshot.json +1769 -0
  178. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +70 -0
  179. package/templates/assistkick-product-system/packages/shared/db/schema.ts +40 -1
  180. package/templates/assistkick-product-system/packages/shared/lib/claude-service.test.ts +236 -0
  181. package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +46 -5
  182. package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +65 -39
  183. package/templates/assistkick-product-system/packages/shared/lib/programmable_node_executor.test.ts +173 -0
  184. package/templates/assistkick-product-system/packages/shared/lib/programmable_node_executor.ts +213 -0
  185. package/templates/assistkick-product-system/packages/shared/lib/validator.test.ts +70 -0
  186. package/templates/assistkick-product-system/packages/shared/lib/validator.ts +17 -1
  187. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +803 -27
  188. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +502 -68
  189. package/templates/assistkick-product-system/packages/shared/lib/workflow_orchestrator.ts +4 -4
  190. package/templates/assistkick-product-system/packages/shared/package.json +2 -1
  191. package/templates/assistkick-product-system/packages/shared/test_fixtures/hanging_stream.mjs +46 -0
  192. package/templates/assistkick-product-system/packages/shared/tools/add_node.test.ts +44 -0
  193. package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +7 -0
  194. package/templates/assistkick-product-system/packages/shared/tools/remove_node.ts +2 -1
  195. package/templates/assistkick-product-system/packages/shared/tools/resolve_question.ts +2 -1
  196. package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -1
  197. package/templates/assistkick-product-system/tests/message_queue.test.ts +178 -0
  198. package/templates/assistkick-product-system/tests/message_queue_per_session.test.ts +143 -0
  199. package/templates/skills/assistkick-bootstrap/SKILL.md +26 -26
  200. package/templates/skills/assistkick-code-reviewer/SKILL.md +45 -46
  201. package/templates/skills/assistkick-db-explorer/SKILL.md +13 -13
  202. package/templates/skills/assistkick-debugger/SKILL.md +23 -23
  203. package/templates/skills/assistkick-developer/SKILL.md +59 -63
  204. package/templates/skills/assistkick-interview/SKILL.md +26 -26
  205. package/templates/skills/assistkick-video-composition-agent/SKILL.md +231 -0
  206. package/templates/skills/assistkick-video-script-writer/SKILL.md +136 -0
@@ -14,8 +14,10 @@
14
14
  */
15
15
 
16
16
  import { randomUUID } from 'node:crypto';
17
- import { resolve as pathResolve, dirname } from 'node:path';
17
+ import { readFileSync } from 'node:fs';
18
+ import { resolve as pathResolve, dirname, join as pathJoin } from 'node:path';
18
19
  import { fileURLToPath } from 'node:url';
20
+ import type { ChildProcess } from 'node:child_process';
19
21
  import { eq, and } from 'drizzle-orm';
20
22
  import {
21
23
  workflows,
@@ -25,6 +27,7 @@ import {
25
27
  agents,
26
28
  nodes as nodesTable,
27
29
  } from '../db/schema.js';
30
+ import { ProgrammableNodeExecutor } from './programmable_node_executor.js';
28
31
 
29
32
  // ── Types ──────────────────────────────────────────────────────────────
30
33
 
@@ -86,7 +89,7 @@ interface WorkflowEngineDeps {
86
89
  saveKanbanEntry: (featureId: string, entry: Record<string, unknown>, projectId?: string) => Promise<void>;
87
90
  };
88
91
  claudeService: {
89
- spawnClaude: (prompt: string, cwd: string, label?: string, opts?: Record<string, unknown>) => Promise<string>;
92
+ spawnClaude: (prompt: string, cwd: string, label?: string, opts?: Record<string, unknown> & { sessionId?: string; resume?: string; model?: string }) => Promise<string>;
90
93
  spawnCommand: (cmd: string, args: string[], cwd: string) => Promise<string>;
91
94
  };
92
95
  gitWorkflow: {
@@ -100,11 +103,14 @@ interface WorkflowEngineDeps {
100
103
  stash: () => Promise<boolean>;
101
104
  unstash: () => Promise<void>;
102
105
  getDirtyFiles: () => Promise<string[]>;
106
+ pullDefaultBranch: (projectId: string, branch?: string) => Promise<void>;
107
+ pushToRemote: (projectId: string, branch?: string) => Promise<void>;
103
108
  };
109
+ skillsDir: string;
104
110
  bundleService?: BundleServiceDep;
105
111
  ttsService?: TtsServiceDep;
106
112
  videoRenderService?: VideoRenderServiceDep;
107
- log: (tag: string, message: string) => void;
113
+ log: (tag: string, ...args: any[]) => void;
108
114
  }
109
115
 
110
116
  // ── WorkflowEngine ────────────────────────────────────────────────────
@@ -114,20 +120,29 @@ export class WorkflowEngine {
114
120
  private kanban: WorkflowEngineDeps['kanban'];
115
121
  private claudeService: WorkflowEngineDeps['claudeService'];
116
122
  private gitWorkflow: WorkflowEngineDeps['gitWorkflow'];
123
+ private skillsDir: string;
117
124
  private bundleService?: BundleServiceDep;
118
125
  private ttsService?: TtsServiceDep;
119
126
  private videoRenderService?: VideoRenderServiceDep;
120
- private log: WorkflowEngineDeps['log'];
127
+ private log: (tag: string, ...args: any[]) => void;
128
+ private programmableExecutor: ProgrammableNodeExecutor;
121
129
 
122
- constructor({ db, kanban, claudeService, gitWorkflow, bundleService, ttsService, videoRenderService, log }: WorkflowEngineDeps) {
130
+ /** Active Claude child processes keyed by executionId for kill support */
131
+ private activeProcesses = new Map<string, ChildProcess>();
132
+ /** Execution IDs that were stopped by user — prevents executeGraph from overwriting statuses */
133
+ private stoppedExecutions = new Set<string>();
134
+
135
+ constructor({ db, kanban, claudeService, gitWorkflow, skillsDir, bundleService, ttsService, videoRenderService, log }: WorkflowEngineDeps) {
123
136
  this.db = db;
124
137
  this.kanban = kanban;
125
138
  this.claudeService = claudeService;
126
139
  this.gitWorkflow = gitWorkflow;
140
+ this.skillsDir = skillsDir;
127
141
  this.bundleService = bundleService;
128
142
  this.ttsService = ttsService;
129
143
  this.videoRenderService = videoRenderService;
130
144
  this.log = log;
145
+ this.programmableExecutor = new ProgrammableNodeExecutor({ log });
131
146
  }
132
147
 
133
148
  // ── Public API ──────────────────────────────────────────────────────
@@ -152,8 +167,15 @@ export class WorkflowEngine {
152
167
  throw new Error(`Workflow ${workflowId} has no start node`);
153
168
  }
154
169
 
170
+ // Pull latest default branch before creating worktree to ensure up-to-date base
171
+ try {
172
+ await this.gitWorkflow.pullDefaultBranch(projectId);
173
+ } catch (pullErr: any) {
174
+ this.log('WORKFLOW', `Pull before worktree creation failed (non-fatal):`, pullErr.stack || pullErr.message);
175
+ }
176
+
155
177
  // Create worktree and feature branch
156
- const worktreePath = await this.gitWorkflow.createWorktree(featureId);
178
+ const worktreePath = await this.gitWorkflow.createWorktree(featureId, projectId);
157
179
  const branchName = `feature/${featureId}`;
158
180
 
159
181
  // Build initial context
@@ -172,6 +194,7 @@ export class WorkflowEngine {
172
194
  mainSkillDir,
173
195
  mainToolsDir,
174
196
  pidFlag,
197
+ nodeOutputs: {},
175
198
  };
176
199
 
177
200
  // Enrich context with feature data for agent prompt resolution
@@ -207,8 +230,8 @@ export class WorkflowEngine {
207
230
 
208
231
  // Fire-and-forget: traverse the graph asynchronously
209
232
  this.executeGraph(executionId, graph, startNode.id, context).catch(err => {
210
- this.log('WORKFLOW', `UNCAUGHT ERROR in execution ${executionId}: ${err.message}`);
211
- this.setExecutionStatus(executionId, 'failed', null, context).catch(() => {});
233
+ this.log('WORKFLOW', `UNCAUGHT ERROR in execution ${executionId}:`, err.stack || err.message);
234
+ this.setExecutionStatus(executionId, 'failed', null, context).catch((e: any) => { this.log('WORKFLOW', 'Failed to set execution status:', e.message); });
212
235
  });
213
236
 
214
237
  return { executionId };
@@ -256,7 +279,7 @@ export class WorkflowEngine {
256
279
  eq(workflowNodeExecutions.nodeId, currentNodeId),
257
280
  ));
258
281
 
259
- const failedExec = nodeExecs.find(ne => ne.status === 'failed' || ne.status === 'running');
282
+ const failedExec = nodeExecs.find(ne => ne.status === 'failed' || ne.status === 'running' || ne.status === 'stopped');
260
283
  if (failedExec) {
261
284
  await this.db.update(workflowNodeExecutions)
262
285
  .set({ status: 'pending', attempt: failedExec.attempt + 1 })
@@ -270,8 +293,8 @@ export class WorkflowEngine {
270
293
 
271
294
  // Resume graph traversal from the current node
272
295
  this.executeGraph(executionId, graph, currentNodeId, context).catch(err => {
273
- this.log('WORKFLOW', `UNCAUGHT ERROR resuming ${executionId}: ${err.message}`);
274
- this.setExecutionStatus(executionId, 'failed', currentNodeId, context).catch(() => {});
296
+ this.log('WORKFLOW', `UNCAUGHT ERROR resuming ${executionId}:`, err.stack || err.message);
297
+ this.setExecutionStatus(executionId, 'failed', currentNodeId, context).catch((e: any) => { this.log('WORKFLOW', 'Failed to set execution status:', e.message); });
275
298
  });
276
299
  };
277
300
 
@@ -301,7 +324,7 @@ export class WorkflowEngine {
301
324
  eq(workflowNodeExecutions.executionId, execution.id),
302
325
  eq(workflowNodeExecutions.nodeId, nodeId),
303
326
  ));
304
- const currentNodeExec = nodeExecs.find(ne => ne.status === 'running' || ne.status === 'failed');
327
+ const currentNodeExec = nodeExecs.find(ne => ne.status === 'running' || ne.status === 'failed' || ne.status === 'stopped');
305
328
  if (currentNodeExec) {
306
329
  await this.db.update(workflowNodeExecutions)
307
330
  .set({ status: 'completed', completedAt: new Date().toISOString() })
@@ -326,8 +349,8 @@ export class WorkflowEngine {
326
349
 
327
350
  // Resume graph traversal from the next node
328
351
  this.executeGraph(execution.id, graph, nextNodeId, context).catch(err => {
329
- this.log('WORKFLOW', `UNCAUGHT ERROR after force-next ${execution.id}: ${err.message}`);
330
- this.setExecutionStatus(execution.id, 'failed', nextNodeId, context).catch(() => {});
352
+ this.log('WORKFLOW', `UNCAUGHT ERROR after force-next ${execution.id}:`, err.stack || err.message);
353
+ this.setExecutionStatus(execution.id, 'failed', nextNodeId, context).catch((e: any) => { this.log('WORKFLOW', 'Failed to set execution status:', e.message); });
331
354
  });
332
355
  };
333
356
 
@@ -356,7 +379,7 @@ export class WorkflowEngine {
356
379
  eq(workflowNodeExecutions.executionId, execution.id),
357
380
  eq(workflowNodeExecutions.nodeId, nodeId),
358
381
  ));
359
- const currentNodeExec = nodeExecs.find(ne => ne.status === 'running' || ne.status === 'failed');
382
+ const currentNodeExec = nodeExecs.find(ne => ne.status === 'running' || ne.status === 'failed' || ne.status === 'stopped');
360
383
  if (currentNodeExec) {
361
384
  await this.db.update(workflowNodeExecutions)
362
385
  .set({ status: 'pending', attempt: currentNodeExec.attempt + 1 })
@@ -370,11 +393,268 @@ export class WorkflowEngine {
370
393
 
371
394
  // Re-execute from the current node
372
395
  this.executeGraph(execution.id, graph, nodeId, context).catch(err => {
373
- this.log('WORKFLOW', `UNCAUGHT ERROR after restart-node ${execution.id}: ${err.message}`);
374
- this.setExecutionStatus(execution.id, 'failed', nodeId, context).catch(() => {});
396
+ this.log('WORKFLOW', `UNCAUGHT ERROR after restart-node ${execution.id}:`, err.stack || err.message);
397
+ this.setExecutionStatus(execution.id, 'failed', nodeId, context).catch((e: any) => { this.log('WORKFLOW', 'Failed to set execution status:', e.message); });
375
398
  });
376
399
  };
377
400
 
401
+ /**
402
+ * Stop the currently executing workflow node.
403
+ * Sends SIGTERM to the Claude child process, waits 3s, then SIGKILL if still alive.
404
+ * Marks node execution as 'stopped' and workflow execution as 'failed'.
405
+ * Does NOT clean up the worktree (same as error behavior — preserves work for resume).
406
+ */
407
+ stopNode = async (featureId: string, nodeId: string): Promise<void> => {
408
+ this.log('WORKFLOW', `Stop-node requested for feature ${featureId}, node ${nodeId}`);
409
+
410
+ const execution = await this.findLatestExecution(featureId);
411
+ if (!execution || execution.status !== 'running') {
412
+ throw new Error(`No running execution found for feature ${featureId}`);
413
+ }
414
+ if (execution.currentNodeId !== nodeId) {
415
+ throw new Error(`Node ${nodeId} is not the current execution node (current: ${execution.currentNodeId})`);
416
+ }
417
+
418
+ // Mark as stopped BEFORE killing to prevent executeGraph from overwriting statuses
419
+ this.stoppedExecutions.add(execution.id);
420
+
421
+ // Update node execution → 'stopped'
422
+ const nodeExecs = await this.db.select().from(workflowNodeExecutions)
423
+ .where(and(
424
+ eq(workflowNodeExecutions.executionId, execution.id),
425
+ eq(workflowNodeExecutions.nodeId, nodeId),
426
+ ));
427
+ const runningExec = nodeExecs.find(ne => ne.status === 'running');
428
+ if (runningExec) {
429
+ await this.db.update(workflowNodeExecutions)
430
+ .set({
431
+ status: 'stopped',
432
+ error: 'Stopped by user',
433
+ completedAt: new Date().toISOString(),
434
+ })
435
+ .where(eq(workflowNodeExecutions.id, runningExec.id));
436
+ }
437
+
438
+ // Update workflow execution → 'failed'
439
+ const context: ExecutionContext = JSON.parse(execution.context);
440
+ await this.setExecutionStatus(execution.id, 'failed', nodeId, context);
441
+
442
+ // Kill the child process
443
+ const child = this.activeProcesses.get(execution.id);
444
+ if (child) {
445
+ this.log('WORKFLOW', `Sending SIGTERM to child process for execution ${execution.id}`);
446
+ child.kill('SIGTERM');
447
+ // After 3s, send SIGKILL if still alive
448
+ setTimeout(() => {
449
+ try { child.kill('SIGKILL'); } catch { /* already dead */ }
450
+ }, 3000);
451
+ }
452
+
453
+ this.log('WORKFLOW', `Node ${nodeId} stopped for feature ${featureId}`);
454
+ };
455
+
456
+ /**
457
+ * Continue a stuck/failed agent node by resuming the Claude session.
458
+ * Spawns a new Claude process with '--resume <sessionId>' using the original session ID.
459
+ * If the process is still alive, kills it first before resuming.
460
+ */
461
+ continueNode = async (featureId: string, nodeId: string): Promise<void> => {
462
+ this.log('WORKFLOW', `Continue-node requested for feature ${featureId}, node ${nodeId}`);
463
+
464
+ const execution = await this.findLatestExecution(featureId);
465
+ if (!execution) throw new Error(`No execution found for feature ${featureId}`);
466
+ if (execution.currentNodeId !== nodeId) {
467
+ throw new Error(`Node ${nodeId} is not the current execution node (current: ${execution.currentNodeId})`);
468
+ }
469
+
470
+ // Find the node execution record with the Claude session ID
471
+ const nodeExecs = await this.db.select().from(workflowNodeExecutions)
472
+ .where(and(
473
+ eq(workflowNodeExecutions.executionId, execution.id),
474
+ eq(workflowNodeExecutions.nodeId, nodeId),
475
+ ));
476
+ const nodeExec = nodeExecs.sort((a, b) => b.attempt - a.attempt)[0];
477
+ if (!nodeExec) throw new Error(`No execution record found for node ${nodeId}`);
478
+ if (!nodeExec.claudeSessionId) {
479
+ throw new Error(`Node ${nodeId} has no Claude session ID — not an agent node`);
480
+ }
481
+
482
+ // If the process is still alive, kill it first
483
+ const existingChild = this.activeProcesses.get(execution.id);
484
+ if (existingChild) {
485
+ this.log('WORKFLOW', `Killing existing process for execution ${execution.id} before resuming`);
486
+ this.stoppedExecutions.add(execution.id);
487
+ existingChild.kill('SIGTERM');
488
+ await new Promise<void>(resolve => {
489
+ const timeout = setTimeout(() => {
490
+ try { existingChild.kill('SIGKILL'); } catch { /* already dead */ }
491
+ resolve();
492
+ }, 3000);
493
+ existingChild.on('close', () => { clearTimeout(timeout); resolve(); });
494
+ });
495
+ this.activeProcesses.delete(execution.id);
496
+ this.stoppedExecutions.delete(execution.id);
497
+ }
498
+
499
+ // Reset node execution status to 'running' — do NOT increment attempt (continuation, not retry)
500
+ await this.db.update(workflowNodeExecutions)
501
+ .set({
502
+ status: 'running',
503
+ error: null,
504
+ completedAt: null,
505
+ })
506
+ .where(eq(workflowNodeExecutions.id, nodeExec.id));
507
+
508
+ // Set workflow execution back to 'running'
509
+ const context: ExecutionContext = JSON.parse(execution.context);
510
+ await this.db.update(workflowExecutions)
511
+ .set({ status: 'running', updatedAt: new Date().toISOString() })
512
+ .where(eq(workflowExecutions.id, execution.id));
513
+
514
+ // Load workflow graph for post-continue traversal
515
+ const [workflow] = await this.db.select().from(workflows)
516
+ .where(eq(workflows.id, execution.workflowId));
517
+ if (!workflow) throw new Error(`Workflow ${execution.workflowId} not found`);
518
+ const graph: WorkflowGraph = JSON.parse(workflow.graphData);
519
+
520
+ // Resolve agent name for logging
521
+ const graphNode = graph.nodes.find(n => n.id === nodeId);
522
+ const agentId = (graphNode?.data as { agentId?: string })?.agentId;
523
+ let agentName = 'agent';
524
+ if (agentId) {
525
+ const [agent] = await this.db.select().from(agents).where(eq(agents.id, agentId));
526
+ if (agent) agentName = agent.name;
527
+ }
528
+
529
+ // Set up the same streaming callbacks as handleRunAgent
530
+ const toolCalls: Record<string, number> = { total: 0, read: 0, write: 0, edit: 0, bash: 0, glob: 0, grep: 0 };
531
+ let costUsd: number | null = null;
532
+ let numTurns: number | null = null;
533
+ let stopReason: string | null = null;
534
+ let model: string | null = null;
535
+ let contextWindowPct: number | null = null;
536
+
537
+ const onToolUse = (toolName: string, toolInput: object) => {
538
+ toolCalls.total++;
539
+ const key = toolName.toLowerCase();
540
+ if (key in toolCalls) toolCalls[key]++;
541
+
542
+ // Append tool calls to existing records — no duplication
543
+ const target = this.extractToolTarget(toolName, toolInput);
544
+ this.db.insert(workflowToolCalls).values({
545
+ id: randomUUID(),
546
+ nodeExecutionId: nodeExec.id,
547
+ timestamp: new Date().toISOString(),
548
+ toolName,
549
+ target,
550
+ createdAt: new Date().toISOString(),
551
+ }).catch(() => {});
552
+ };
553
+
554
+ const onAssistantText = (text: string) => {
555
+ this.db.insert(workflowToolCalls).values({
556
+ id: randomUUID(),
557
+ nodeExecutionId: nodeExec.id,
558
+ timestamp: new Date().toISOString(),
559
+ toolName: '__assistant__',
560
+ target: text,
561
+ createdAt: new Date().toISOString(),
562
+ }).catch(() => {});
563
+ };
564
+
565
+ const onResult = (metadata: {
566
+ costUsd: number | null;
567
+ numTurns: number | null;
568
+ stopReason: string | null;
569
+ model: string | null;
570
+ contextWindow: number | null;
571
+ }) => {
572
+ costUsd = metadata.costUsd;
573
+ numTurns = metadata.numTurns;
574
+ stopReason = metadata.stopReason;
575
+ model = metadata.model;
576
+ contextWindowPct = metadata.contextWindow;
577
+ };
578
+
579
+ const onSpawn = (child: ChildProcess) => {
580
+ this.activeProcesses.set(execution.id, child);
581
+ };
582
+
583
+ // Fire-and-forget: resume the Claude session and continue graph traversal
584
+ const { worktreePath } = context;
585
+ const label = `${agentName.toLowerCase().replace(/\s+/g, '-')}:${featureId}`;
586
+
587
+ this.log('WORKFLOW', `Resuming Claude session ${nodeExec.claudeSessionId} for agent "${agentName}"`);
588
+
589
+ (async () => {
590
+ try {
591
+ const output = await this.claudeService.spawnClaude(
592
+ 'Continue',
593
+ worktreePath,
594
+ label,
595
+ { onToolUse, onResult, onAssistantText, onSpawn, resume: nodeExec.claudeSessionId },
596
+ );
597
+ this.activeProcesses.delete(execution.id);
598
+
599
+ // If execution was stopped while continuing, exit
600
+ if (this.stoppedExecutions.has(execution.id)) {
601
+ this.stoppedExecutions.delete(execution.id);
602
+ return;
603
+ }
604
+
605
+ // Commit any changes
606
+ try {
607
+ await this.gitWorkflow.commitChanges(featureId, worktreePath);
608
+ } catch (err: any) {
609
+ this.log('WORKFLOW', `WARN: Auto-commit after continue failed: ${err.message}`);
610
+ }
611
+
612
+ // Rebase onto main
613
+ try {
614
+ await this.gitWorkflow.rebaseBranch(featureId, worktreePath);
615
+ } catch (err: any) {
616
+ this.log('WORKFLOW', `WARN: Rebase after continue failed: ${err.message}`);
617
+ }
618
+
619
+ // Mark node as completed
620
+ await this.completeNodeExecution(nodeExec.id, {
621
+ agentId,
622
+ agentName,
623
+ output,
624
+ toolCalls,
625
+ costUsd,
626
+ numTurns,
627
+ stopReason,
628
+ model,
629
+ contextWindowPct,
630
+ });
631
+
632
+ this.log('WORKFLOW', `Continued agent "${agentName}" completed — resuming graph traversal`);
633
+
634
+ // Continue graph traversal from next node
635
+ const nextNodeId = this.resolveNextNode(graph, nodeId, null);
636
+ if (nextNodeId) {
637
+ await this.executeGraph(execution.id, graph, nextNodeId, context);
638
+ } else {
639
+ await this.setExecutionStatus(execution.id, 'completed', null, context);
640
+ }
641
+ } catch (err: any) {
642
+ this.activeProcesses.delete(execution.id);
643
+
644
+ if (this.stoppedExecutions.has(execution.id)) {
645
+ this.stoppedExecutions.delete(execution.id);
646
+ return;
647
+ }
648
+
649
+ this.log('WORKFLOW', `Continue-node FAILED: ${err.message}`);
650
+ await this.db.update(workflowNodeExecutions)
651
+ .set({ status: 'failed', error: err.message, completedAt: new Date().toISOString() })
652
+ .where(eq(workflowNodeExecutions.id, nodeExec.id));
653
+ await this.setExecutionStatus(execution.id, 'failed', nodeId, context);
654
+ }
655
+ })();
656
+ };
657
+
378
658
  /**
379
659
  * Find the most recent execution for a feature (any status).
380
660
  * Returns the execution row or null.
@@ -411,7 +691,7 @@ export class WorkflowEngine {
411
691
  .where(eq(workflowNodeExecutions.executionId, execution.id));
412
692
 
413
693
  const completed = nodeExecs.filter(ne => ne.status === 'completed').map(ne => ne.nodeId);
414
- const failed = nodeExecs.filter(ne => ne.status === 'failed').map(ne => ne.nodeId);
694
+ const failed = nodeExecs.filter(ne => ne.status === 'failed' || ne.status === 'stopped').map(ne => ne.nodeId);
415
695
  const pending = nodeExecs.filter(ne => ne.status === 'pending' || ne.status === 'running').map(ne => ne.nodeId);
416
696
 
417
697
  // Resolve current node type and agent name from workflow graph
@@ -436,13 +716,13 @@ export class WorkflowEngine {
436
716
  }
437
717
  }
438
718
  }
439
- } catch { /* non-critical */ }
719
+ } catch (err: any) { this.log('WORKFLOW', 'Non-critical error:', err.message); }
440
720
  }
441
721
 
442
- // Get error message from the most recent failed node execution
722
+ // Get error message from the most recent failed/stopped node execution
443
723
  let errorMessage: string | null = null;
444
724
  if (execution.status === 'failed') {
445
- const failedExec = nodeExecs.find(ne => ne.status === 'failed' && ne.error);
725
+ const failedExec = nodeExecs.find(ne => (ne.status === 'failed' || ne.status === 'stopped') && ne.error);
446
726
  if (failedExec) errorMessage = failedExec.error;
447
727
  }
448
728
 
@@ -555,7 +835,7 @@ export class WorkflowEngine {
555
835
  this.log('WORKFLOW', `Executing node ${node.id} (${node.type})`);
556
836
 
557
837
  // Create or reset node execution record
558
- const nodeExecId = await this.upsertNodeExecution(executionId, node.id, 'running');
838
+ const nodeExecId = await this.upsertNodeExecution(executionId, node.id, 'running', context.cycle || 1);
559
839
 
560
840
  try {
561
841
  const result = await this.executeNode(node, context, executionId);
@@ -564,6 +844,14 @@ export class WorkflowEngine {
564
844
  await this.completeNodeExecution(nodeExecId, result.outputData || null);
565
845
  this.log('WORKFLOW', `Node ${node.id} completed (output: ${result.outputLabel || 'default'})`);
566
846
 
847
+ // Store output in context.nodeOutputs under the node's label (dec_2266dcd3)
848
+ if (result.outputData) {
849
+ const nodeOutputs = (context.nodeOutputs || {}) as Record<string, unknown>;
850
+ const nodeLabel = (node.data?.label as string) || node.id;
851
+ nodeOutputs[nodeLabel] = result.outputData;
852
+ context.nodeOutputs = nodeOutputs;
853
+ }
854
+
567
855
  // Find the next node via edges
568
856
  currentNodeId = this.resolveNextNode(graph, node.id, result.outputLabel);
569
857
 
@@ -573,7 +861,14 @@ export class WorkflowEngine {
573
861
  this.log('WORKFLOW', `No outgoing edge from ${node.id} — traversal complete`);
574
862
  }
575
863
  } catch (err: any) {
576
- this.log('WORKFLOW', `Node ${node.id} FAILED: ${err.message}`);
864
+ // If this execution was stopped by user, statuses are already set — just exit
865
+ if (this.stoppedExecutions.has(executionId)) {
866
+ this.stoppedExecutions.delete(executionId);
867
+ this.log('WORKFLOW', `Node ${node.id} stopped by user — exiting graph traversal`);
868
+ return;
869
+ }
870
+
871
+ this.log('WORKFLOW', `Node ${node.id} FAILED:`, err.stack || err.message);
577
872
 
578
873
  // Mark node execution as failed
579
874
  await this.db.update(workflowNodeExecutions)
@@ -629,6 +924,8 @@ export class WorkflowEngine {
629
924
  return this.handleGenerateTTS(node, context);
630
925
  case 'renderVideo':
631
926
  return this.handleRenderVideo(node, context);
927
+ case 'programmable':
928
+ return this.handleProgrammable(node, context);
632
929
  default:
633
930
  throw new Error(`Unknown node type: ${node.type}`);
634
931
  }
@@ -687,8 +984,23 @@ export class WorkflowEngine {
687
984
  throw new Error(`Agent ${agentId} not found`);
688
985
  }
689
986
 
690
- // Resolve {{placeholder}} variables in prompt template
691
- const resolvedPrompt = this.resolvePromptTemplate(agent.promptTemplate, context);
987
+ // Load skill file contents from disk (never interpolated)
988
+ const skillIdentifiers: string[] = JSON.parse(agent.skills);
989
+ const skillContents: string[] = [];
990
+ for (const skillId of skillIdentifiers) {
991
+ const skillPath = pathJoin(this.skillsDir, skillId, 'SKILL.md');
992
+ try {
993
+ skillContents.push(readFileSync(skillPath, 'utf-8'));
994
+ } catch (err: any) {
995
+ throw new Error(`Failed to read skill file for "${skillId}" at ${skillPath}: ${err.message}`);
996
+ }
997
+ }
998
+
999
+ // Resolve {{placeholder}} variables only in the grounding portion
1000
+ const resolvedGrounding = this.resolvePromptTemplate(agent.grounding, context);
1001
+
1002
+ // Compose final system prompt: skill contents + resolved grounding
1003
+ const resolvedPrompt = [...skillContents, resolvedGrounding].join('\n\n');
692
1004
 
693
1005
  // Find the node execution ID for persisting tool calls
694
1006
  const nodeExecs = await this.db.select().from(workflowNodeExecutions)
@@ -696,9 +1008,18 @@ export class WorkflowEngine {
696
1008
  eq(workflowNodeExecutions.executionId, executionId),
697
1009
  eq(workflowNodeExecutions.nodeId, node.id),
698
1010
  ));
699
- const currentNodeExecId = nodeExecs.length > 0
700
- ? nodeExecs.sort((a, b) => b.attempt - a.attempt)[0].id
1011
+ const currentNodeExec = nodeExecs.length > 0
1012
+ ? nodeExecs.sort((a, b) => b.attempt - a.attempt)[0]
701
1013
  : null;
1014
+ const currentNodeExecId = currentNodeExec?.id ?? null;
1015
+
1016
+ // Generate a Claude session ID and store it on the node execution record
1017
+ const claudeSessionId = randomUUID();
1018
+ if (currentNodeExecId) {
1019
+ await this.db.update(workflowNodeExecutions)
1020
+ .set({ claudeSessionId })
1021
+ .where(eq(workflowNodeExecutions.id, currentNodeExecId));
1022
+ }
702
1023
 
703
1024
  // Accumulate KPI metrics during execution
704
1025
  const toolCalls: Record<string, number> = { total: 0, read: 0, write: 0, edit: 0, bash: 0, glob: 0, grep: 0 };
@@ -757,26 +1078,36 @@ export class WorkflowEngine {
757
1078
  contextWindowPct = metadata.contextWindow;
758
1079
  };
759
1080
 
1081
+ // Track child process for kill support
1082
+ const onSpawn = (child: ChildProcess) => {
1083
+ this.activeProcesses.set(executionId, child);
1084
+ };
1085
+
760
1086
  this.log('WORKFLOW', `Running agent "${agent.name}" (${agentId}) for ${featureId}`);
761
- const output = await this.claudeService.spawnClaude(
762
- resolvedPrompt,
763
- worktreePath,
764
- `${agent.name.toLowerCase().replace(/\s+/g, '-')}:${featureId}`,
765
- { onToolUse, onResult, onAssistantText },
766
- );
1087
+ let output: string;
1088
+ try {
1089
+ output = await this.claudeService.spawnClaude(
1090
+ resolvedPrompt,
1091
+ worktreePath,
1092
+ `${agent.name.toLowerCase().replace(/\s+/g, '-')}:${featureId}`,
1093
+ { onToolUse, onResult, onAssistantText, onSpawn, sessionId: claudeSessionId, model: agent.model },
1094
+ );
1095
+ } finally {
1096
+ this.activeProcesses.delete(executionId);
1097
+ }
767
1098
 
768
1099
  // Commit any changes the agent made
769
1100
  try {
770
1101
  await this.gitWorkflow.commitChanges(featureId, worktreePath);
771
1102
  } catch (err: any) {
772
- this.log('WORKFLOW', `WARN: Auto-commit after agent failed: ${err.message}`);
1103
+ this.log('WORKFLOW', `WARN: Auto-commit after agent failed:`, err.stack || err.message);
773
1104
  }
774
1105
 
775
1106
  // Rebase onto main
776
1107
  try {
777
1108
  await this.gitWorkflow.rebaseBranch(featureId, worktreePath);
778
1109
  } catch (err: any) {
779
- this.log('WORKFLOW', `WARN: Rebase after agent failed: ${err.message}`);
1110
+ this.log('WORKFLOW', `WARN: Rebase after agent failed:`, err.stack || err.message);
780
1111
  }
781
1112
 
782
1113
  this.log('WORKFLOW', `Agent "${agent.name}" completed (${output.length} chars output)`);
@@ -895,11 +1226,11 @@ export class WorkflowEngine {
895
1226
  await this.handleMerge(featureId, worktreePath, context);
896
1227
  await this.setExecutionStatus(executionId, 'completed', node.id, context);
897
1228
  } else if (outcome === 'blocked') {
898
- await this.cleanupWorktree(featureId, worktreePath);
1229
+ await this.cleanupWorktree(featureId, worktreePath, context.projectId);
899
1230
  await this.setExecutionStatus(executionId, 'completed', node.id, context);
900
1231
  } else {
901
1232
  // failed or unknown outcome
902
- await this.cleanupWorktree(featureId, worktreePath);
1233
+ await this.cleanupWorktree(featureId, worktreePath, context.projectId);
903
1234
  await this.setExecutionStatus(executionId, 'failed', node.id, context);
904
1235
  }
905
1236
 
@@ -1075,6 +1406,67 @@ export class WorkflowEngine {
1075
1406
  };
1076
1407
  };
1077
1408
 
1409
+ /**
1410
+ * Programmable — executes user-written JavaScript in an isolated-vm V8 isolate.
1411
+ * Returns { outputLabel: 'success', outputData } on success,
1412
+ * or { outputLabel: 'error', outputData: { error } } on throw.
1413
+ *
1414
+ * The error output label mechanism:
1415
+ * - If the 'error' output handle is wired → workflow continues on the error path.
1416
+ * - If the 'error' output handle is NOT wired → resolveNextNode throws → workflow fails.
1417
+ */
1418
+ private handleProgrammable = async (
1419
+ node: WorkflowNode,
1420
+ context: ExecutionContext,
1421
+ ): Promise<NodeHandlerResult> => {
1422
+ const { code, label, timeout, memory, maxRequests, maxResponseSize } = node.data as {
1423
+ code: string;
1424
+ label: string;
1425
+ timeout?: number;
1426
+ memory?: number;
1427
+ maxRequests?: number;
1428
+ maxResponseSize?: number;
1429
+ };
1430
+
1431
+ if (!code || !code.trim()) {
1432
+ throw new Error(`Programmable node "${label || node.id}" has no code`);
1433
+ }
1434
+
1435
+ const nodeLabel = label || node.id;
1436
+ this.log('WORKFLOW', `Running programmable node "${nodeLabel}"`);
1437
+
1438
+ // Build the context object that the user's run(context) receives.
1439
+ // Include nodeOutputs from previous nodes for data piping.
1440
+ const sandboxContext: Record<string, unknown> = {
1441
+ featureId: context.featureId,
1442
+ projectId: context.projectId,
1443
+ cycle: context.cycle,
1444
+ nodeOutputs: context.nodeOutputs || {},
1445
+ };
1446
+
1447
+ const executionResult = await this.programmableExecutor.execute(code, sandboxContext, {
1448
+ timeout,
1449
+ memory,
1450
+ maxRequests,
1451
+ maxResponseSize,
1452
+ });
1453
+
1454
+ if (executionResult.success) {
1455
+ this.log('WORKFLOW', `Programmable node "${nodeLabel}" succeeded`);
1456
+ return {
1457
+ outputLabel: 'success',
1458
+ outputData: executionResult.result as Record<string, unknown> ?? {},
1459
+ };
1460
+ }
1461
+
1462
+ // User code threw — follow the error output handle
1463
+ this.log('WORKFLOW', `Programmable node "${nodeLabel}" errored: ${executionResult.error}`);
1464
+ return {
1465
+ outputLabel: 'error',
1466
+ outputData: { error: executionResult.error },
1467
+ };
1468
+ };
1469
+
1078
1470
  // ── Merge & Cleanup Helpers ─────────────────────────────────────────
1079
1471
 
1080
1472
  /**
@@ -1090,20 +1482,20 @@ export class WorkflowEngine {
1090
1482
  // Stash local changes on the parent branch
1091
1483
  let stashed = false;
1092
1484
  try {
1093
- stashed = await this.gitWorkflow.stash();
1485
+ stashed = await this.gitWorkflow.stash(context.projectId);
1094
1486
  } catch (err: any) {
1095
- this.log('WORKFLOW', `Stash failed (non-fatal): ${err.message}`);
1487
+ this.log('WORKFLOW', `Stash failed (non-fatal):`, err.stack || err.message);
1096
1488
  }
1097
1489
 
1098
1490
  // Merge
1099
1491
  let mergeSucceeded = false;
1100
1492
  try {
1101
- await this.gitWorkflow.mergeBranch(featureId);
1493
+ await this.gitWorkflow.mergeBranch(featureId, context.projectId);
1102
1494
  mergeSucceeded = true;
1103
1495
  } catch (mergeErr: any) {
1104
- this.log('WORKFLOW', `Merge FAILED: ${mergeErr.message} — attempting fix`);
1496
+ this.log('WORKFLOW', `Merge FAILED:`, mergeErr.stack || mergeErr.message, '— attempting fix');
1105
1497
  try {
1106
- const fixed = await this.gitWorkflow.fixMergeConflicts(featureId);
1498
+ const fixed = await this.gitWorkflow.fixMergeConflicts(featureId, context.projectId);
1107
1499
  if (fixed) {
1108
1500
  this.log('WORKFLOW', `Merge fix succeeded`);
1109
1501
  mergeSucceeded = true;
@@ -1111,7 +1503,7 @@ export class WorkflowEngine {
1111
1503
  throw new Error(`Merge fix did not result in a successful merge`);
1112
1504
  }
1113
1505
  } catch (fixErr: any) {
1114
- this.log('WORKFLOW', `Merge fix FAILED: ${fixErr.message}`);
1506
+ this.log('WORKFLOW', `Merge fix FAILED:`, fixErr.stack || fixErr.message);
1115
1507
  throw new Error(`Merge failed: ${mergeErr.message}`);
1116
1508
  }
1117
1509
  }
@@ -1119,16 +1511,26 @@ export class WorkflowEngine {
1119
1511
  // Pop stash
1120
1512
  if (stashed) {
1121
1513
  try {
1122
- await this.gitWorkflow.unstash();
1514
+ await this.gitWorkflow.unstash(context.projectId);
1123
1515
  } catch (popErr: any) {
1124
- this.log('WORKFLOW', `Stash pop failed: ${popErr.message}`);
1516
+ this.log('WORKFLOW', `Stash pop failed:`, popErr.stack || popErr.message);
1517
+ }
1518
+ }
1519
+
1520
+ // Push to remote after successful merge (non-fatal — merge already succeeded locally)
1521
+ if (mergeSucceeded) {
1522
+ try {
1523
+ await this.gitWorkflow.pushToRemote(context.projectId);
1524
+ this.log('WORKFLOW', `Push to remote succeeded for ${featureId}`);
1525
+ } catch (pushErr: any) {
1526
+ this.log('WORKFLOW', `WARN: Push to remote failed (non-fatal, merge succeeded locally):`, pushErr.stack || pushErr.message);
1125
1527
  }
1126
1528
  }
1127
1529
 
1128
1530
  // Cleanup worktree + branch
1129
1531
  if (mergeSucceeded) {
1130
- await this.gitWorkflow.removeWorktree(worktreePath);
1131
- await this.gitWorkflow.deleteBranch(featureId);
1532
+ await this.gitWorkflow.removeWorktree(worktreePath, context.projectId);
1533
+ await this.gitWorkflow.deleteBranch(featureId, context.projectId);
1132
1534
  this.log('WORKFLOW', `Merge completed, worktree and branch cleaned up for ${featureId}`);
1133
1535
  }
1134
1536
  };
@@ -1136,10 +1538,10 @@ export class WorkflowEngine {
1136
1538
  /**
1137
1539
  * Remove the worktree and branch without merging.
1138
1540
  */
1139
- private cleanupWorktree = async (featureId: string, worktreePath: string): Promise<void> => {
1541
+ private cleanupWorktree = async (featureId: string, worktreePath: string, projectId?: string): Promise<void> => {
1140
1542
  this.log('WORKFLOW', `Cleaning up worktree for ${featureId} (no merge)`);
1141
- await this.gitWorkflow.removeWorktree(worktreePath);
1142
- await this.gitWorkflow.deleteBranch(featureId, true);
1543
+ await this.gitWorkflow.removeWorktree(worktreePath, projectId!);
1544
+ await this.gitWorkflow.deleteBranch(featureId, projectId!, true);
1143
1545
  };
1144
1546
 
1145
1547
  // ── Graph Helpers ───────────────────────────────────────────────────
@@ -1297,10 +1699,10 @@ export class WorkflowEngine {
1297
1699
  // Parse graph data
1298
1700
  const graphData = JSON.parse(workflow.graphData);
1299
1701
 
1300
- // Build node execution map
1301
- const nodeExecutionMap: Record<string, Record<string, unknown>> = {};
1702
+ // Build node execution map — array per node, sorted by cycle
1703
+ const nodeExecutionMap: Record<string, Array<Record<string, unknown>>> = {};
1302
1704
  for (const ne of nodeExecs) {
1303
- nodeExecutionMap[ne.nodeId] = {
1705
+ const entry = {
1304
1706
  id: ne.id,
1305
1707
  nodeId: ne.nodeId,
1306
1708
  status: ne.status,
@@ -1309,8 +1711,17 @@ export class WorkflowEngine {
1309
1711
  outputData: ne.outputData ? JSON.parse(ne.outputData) : null,
1310
1712
  error: ne.error,
1311
1713
  attempt: ne.attempt,
1714
+ cycle: ne.cycle,
1312
1715
  toolCalls: toolCallsByNodeExec[ne.id] || [],
1716
+ claudeSessionId: ne.claudeSessionId || null,
1717
+ processAlive: this.activeProcesses.has(execution.id) && ne.status === 'running',
1313
1718
  };
1719
+ if (!nodeExecutionMap[ne.nodeId]) nodeExecutionMap[ne.nodeId] = [];
1720
+ nodeExecutionMap[ne.nodeId].push(entry);
1721
+ }
1722
+ // Sort each node's executions by cycle ascending
1723
+ for (const nodeId of Object.keys(nodeExecutionMap)) {
1724
+ nodeExecutionMap[nodeId].sort((a, b) => (a.cycle as number) - (b.cycle as number));
1314
1725
  }
1315
1726
 
1316
1727
  return {
@@ -1367,12 +1778,14 @@ export class WorkflowEngine {
1367
1778
 
1368
1779
  /**
1369
1780
  * Create or reset a node execution record. Returns the record ID.
1370
- * For back-edges (re-execution), increments attempt counter.
1781
+ * For same-cycle re-execution (restart), updates existing record.
1782
+ * For new cycle, creates a new row preserving previous cycle data.
1371
1783
  */
1372
1784
  private upsertNodeExecution = async (
1373
1785
  executionId: string,
1374
1786
  nodeId: string,
1375
1787
  status: string,
1788
+ cycle: number = 1,
1376
1789
  ): Promise<string> => {
1377
1790
  // Check for existing records for this node in this execution
1378
1791
  const existing = await this.db.select().from(workflowNodeExecutions)
@@ -1384,22 +1797,42 @@ export class WorkflowEngine {
1384
1797
  const now = new Date().toISOString();
1385
1798
 
1386
1799
  if (existing.length > 0) {
1387
- // Re-execution (back-edge or resume) update existing record
1388
- const latest = existing.sort((a, b) => b.attempt - a.attempt)[0];
1389
- const newAttempt = latest.status === 'completed' ? latest.attempt + 1 : latest.attempt;
1800
+ // Check if there's already a record for this cycle
1801
+ const sameCycle = existing.find(e => e.cycle === cycle);
1802
+ if (sameCycle) {
1803
+ // Same cycle re-execution (restart) — update existing record
1804
+ const newAttempt = sameCycle.status === 'completed' ? sameCycle.attempt + 1 : sameCycle.attempt;
1390
1805
 
1391
- await this.db.update(workflowNodeExecutions)
1392
- .set({
1393
- status,
1394
- startedAt: now,
1395
- completedAt: null,
1396
- outputData: null,
1397
- error: null,
1398
- attempt: newAttempt,
1399
- })
1400
- .where(eq(workflowNodeExecutions.id, latest.id));
1806
+ await this.db.update(workflowNodeExecutions)
1807
+ .set({
1808
+ status,
1809
+ startedAt: now,
1810
+ completedAt: null,
1811
+ outputData: null,
1812
+ error: null,
1813
+ attempt: newAttempt,
1814
+ })
1815
+ .where(eq(workflowNodeExecutions.id, sameCycle.id));
1816
+
1817
+ return sameCycle.id;
1818
+ }
1819
+
1820
+ // New cycle — create a new row to preserve previous cycle data
1821
+ const id = randomUUID();
1822
+ await this.db.insert(workflowNodeExecutions).values({
1823
+ id,
1824
+ executionId,
1825
+ nodeId,
1826
+ status,
1827
+ startedAt: now,
1828
+ completedAt: null,
1829
+ outputData: null,
1830
+ error: null,
1831
+ attempt: 1,
1832
+ cycle,
1833
+ });
1401
1834
 
1402
- return latest.id;
1835
+ return id;
1403
1836
  }
1404
1837
 
1405
1838
  // First execution of this node
@@ -1414,6 +1847,7 @@ export class WorkflowEngine {
1414
1847
  outputData: null,
1415
1848
  error: null,
1416
1849
  attempt: 1,
1850
+ cycle,
1417
1851
  });
1418
1852
 
1419
1853
  return id;