@assistkick/create 1.2.0 → 1.3.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 (49) hide show
  1. package/package.json +2 -1
  2. package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +231 -0
  3. package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +4 -4
  4. package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +49 -2
  5. package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +82 -0
  6. package/templates/assistkick-product-system/packages/backend/src/server.ts +19 -6
  7. package/templates/assistkick-product-system/packages/backend/src/services/github_app_service.ts +146 -0
  8. package/templates/assistkick-product-system/packages/backend/src/services/init.ts +69 -2
  9. package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +71 -0
  10. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +87 -0
  11. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +194 -0
  12. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -17
  13. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +114 -39
  14. package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +28 -14
  15. package/templates/assistkick-product-system/packages/frontend/src/App.tsx +1 -1
  16. package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +151 -0
  17. package/templates/assistkick-product-system/packages/frontend/src/components/GitRepoModal.tsx +352 -0
  18. package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +208 -95
  19. package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +17 -1
  20. package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +238 -105
  21. package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +15 -13
  22. package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +1 -0
  23. package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +4 -0
  24. package/templates/assistkick-product-system/packages/frontend/src/routes/dashboard.tsx +22 -4
  25. package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +486 -38
  26. package/templates/assistkick-product-system/packages/shared/db/migrations/0001_vengeful_wallop.sql +1 -0
  27. package/templates/assistkick-product-system/packages/shared/db/migrations/0002_greedy_excalibur.sql +4 -0
  28. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0001_snapshot.json +826 -0
  29. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0002_snapshot.json +854 -0
  30. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +14 -0
  31. package/templates/assistkick-product-system/packages/shared/db/schema.ts +5 -0
  32. package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +54 -1
  33. package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +25 -0
  34. package/templates/assistkick-product-system/packages/shared/lib/pipeline-state-store.ts +4 -0
  35. package/templates/assistkick-product-system/packages/shared/lib/pipeline.ts +329 -89
  36. package/templates/assistkick-product-system/packages/shared/lib/pipeline_orchestrator.ts +186 -0
  37. package/templates/assistkick-product-system/packages/shared/tools/db_explorer.ts +275 -0
  38. package/templates/assistkick-product-system/packages/shared/tools/get_kanban.ts +2 -1
  39. package/templates/assistkick-product-system/packages/shared/tools/move_card.ts +3 -2
  40. package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -2
  41. package/templates/assistkick-product-system/tests/kanban.test.ts +1 -1
  42. package/templates/assistkick-product-system/tests/pipeline_stats_all_cards.test.ts +1 -1
  43. package/templates/assistkick-product-system/tests/web_terminal.test.ts +189 -150
  44. package/templates/skills/assistkick-bootstrap/SKILL.md +33 -25
  45. package/templates/skills/assistkick-code-reviewer/SKILL.md +23 -15
  46. package/templates/skills/assistkick-db-explorer/SKILL.md +86 -0
  47. package/templates/skills/assistkick-debugger/SKILL.md +30 -22
  48. package/templates/skills/assistkick-developer/SKILL.md +37 -29
  49. package/templates/skills/assistkick-interview/SKILL.md +34 -26
@@ -3,15 +3,36 @@
3
3
  * The full develop→review→merge cycle, using injected dependencies.
4
4
  */
5
5
 
6
+ import { existsSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
6
9
  const TOOL_CALL_COUNTERS = { Write: 'write', Edit: 'edit', Read: 'read', Bash: 'bash' };
7
10
 
11
+ const emptyStageStats = () => ({
12
+ toolCalls: { total: 0, write: 0, edit: 0, read: 0, bash: 0 },
13
+ usage: null as { input_tokens: number; output_tokens: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number } | null,
14
+ lastTurnUsage: null as { input_tokens: number; output_tokens: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number } | null,
15
+ contextWindow: null as number | null,
16
+ stopReason: null as string | null,
17
+ numTurns: null as number | null,
18
+ costUsd: null as number | null,
19
+ model: null as string | null,
20
+ durationMs: null as number | null,
21
+ });
22
+
23
+ const emptyAllStageStats = () => ({
24
+ in_progress: emptyStageStats(),
25
+ in_review: emptyStageStats(),
26
+ qa: emptyStageStats(),
27
+ });
28
+
8
29
  /**
9
30
  * Pipeline class — orchestrates the develop→review→merge cycle.
10
31
  * Receives PromptBuilder, GitWorkflow, kanban, stateStore, claudeService,
11
32
  * and log via constructor injection.
12
33
  */
13
34
  export class Pipeline {
14
- constructor({ promptBuilder, gitWorkflow, claudeService, kanban, paths, log, stateStore = null, workSummaryParser = null, getNode = null }) {
35
+ constructor({ promptBuilder, gitWorkflow, claudeService, kanban, paths, log, stateStore = null, workSummaryParser = null, getNode = null, resolveProjectWorkspace = null }) {
15
36
  this.promptBuilder = promptBuilder;
16
37
  this.gitWorkflow = gitWorkflow;
17
38
  this.claudeService = claudeService;
@@ -21,10 +42,20 @@ export class Pipeline {
21
42
  this.stateStore = stateStore;
22
43
  this.workSummaryParser = workSummaryParser;
23
44
  this.getNode = getNode;
45
+ this.resolveProjectWorkspace = resolveProjectWorkspace;
24
46
  this.pipelines = new Map();
25
47
  this.lastLoggedStatus = new Map();
48
+ // Per-feature git workflow instances (for project-scoped workspaces)
49
+ this.featureWorkflows = new Map();
50
+ // Per-feature afterMerge hooks (push to remote, etc.)
51
+ this.featureAfterMerge = new Map();
26
52
  }
27
53
 
54
+ /** Get the effective GitWorkflow for a feature (project-scoped or default). */
55
+ getGitWorkflow = (featureId) => {
56
+ return this.featureWorkflows.get(featureId) || this.gitWorkflow;
57
+ };
58
+
28
59
  /** Persist state to DB if stateStore is available (fire-and-forget with logging). */
29
60
  persistState = (featureId) => {
30
61
  if (!this.stateStore) return;
@@ -35,7 +66,8 @@ export class Pipeline {
35
66
  });
36
67
  };
37
68
 
38
- run = async (featureId) => {
69
+ run = async (featureId, options: { startCycle?: number; skipDevForFirstCycle?: boolean } = {}) => {
70
+ const { startCycle = 1, skipDevForFirstCycle = false } = options;
39
71
  const state = this.pipelines.get(featureId);
40
72
  if (!state) {
41
73
  this.log('PIPELINE', `No pipeline state found for ${featureId}, aborting`);
@@ -47,94 +79,141 @@ export class Pipeline {
47
79
  let previousReviewNotes = null;
48
80
  let debuggerFindings = null;
49
81
 
50
- // Debug-first step: check for unaddressed QA notes before cycle 1
51
- debuggerFindings = await this.runDebugStep(featureId, state);
82
+ // Debug-first step: only run on a fresh start (cycle 1, no skipped dev)
83
+ if (startCycle === 1 && !skipDevForFirstCycle) {
84
+ debuggerFindings = await this.runDebugStep(featureId, state);
85
+ }
52
86
 
53
- for (let cycle = 1; cycle <= 3; cycle++) {
87
+ for (let cycle = startCycle; cycle <= 3; cycle++) {
54
88
  state.cycle = cycle;
55
89
  this.log('PIPELINE', `--- Cycle ${cycle}/3 for ${featureId} ---`);
56
90
 
57
- // 1. Move card to in_progress (on retries, reviewer left it in todo)
58
- try {
59
- const devEntry = await this.kanban.getKanbanEntry(featureId);
60
- if (devEntry && devEntry.column !== 'in_progress') {
61
- devEntry.column = 'in_progress';
62
- devEntry.moved_at = new Date().toISOString();
63
- await this.kanban.saveKanbanEntry(featureId, devEntry);
64
- this.log('PIPELINE', `Moved ${featureId} → in_progress (cycle ${cycle})`);
65
- }
66
- } catch (err) {
67
- this.log('PIPELINE', `WARN: Failed to move ${featureId} to in_progress: ${err.message}`);
68
- }
91
+ const isFirstCycleAndSkipDev = cycle === startCycle && skipDevForFirstCycle;
69
92
 
70
- // 2. Run developer
71
- state.status = cycle === 1 ? 'developing' : 'fixing_review';
72
- state.tasks = { completed: 0, total: 0, items: [] };
73
- state.toolCalls = { total: 0, write: 0, edit: 0, read: 0, bash: 0 };
74
- this.log('PIPELINE', `Status → ${state.status}`);
75
- this.persistState(featureId);
76
- try {
77
- const devPrompt = await this.promptBuilder.buildDeveloperPrompt(featureId, cycle, previousReviewNotes, debuggerFindings, state.projectId);
78
- if (cycle === 1) debuggerFindings = null;
79
- this.log('PIPELINE', `Spawning developer Claude for ${featureId}...`);
80
- const onToolUse = (toolName, input) => {
81
- if (toolName === 'TodoWrite' && Array.isArray(input.todos)) {
82
- state.tasks.items = input.todos.map(t => ({ name: t.content || t.activeForm || '', status: t.status || 'pending' }));
83
- state.tasks.total = state.tasks.items.length;
84
- state.tasks.completed = state.tasks.items.filter(t => t.status === 'completed').length;
85
- }
86
- state.toolCalls.total += 1;
87
- const counterKey = TOOL_CALL_COUNTERS[toolName];
88
- if (counterKey) {
89
- state.toolCalls[counterKey] += 1;
93
+ if (!isFirstCycleAndSkipDev) {
94
+ // 1. Move card to in_progress (on retries, reviewer left it in todo)
95
+ try {
96
+ const devEntry = await this.kanban.getKanbanEntry(featureId);
97
+ if (devEntry && devEntry.column !== 'in_progress') {
98
+ devEntry.column = 'in_progress';
99
+ devEntry.moved_at = new Date().toISOString();
100
+ await this.kanban.saveKanbanEntry(featureId, devEntry);
101
+ this.log('PIPELINE', `Moved ${featureId} in_progress (cycle ${cycle})`);
90
102
  }
91
- this.persistState(featureId);
92
- };
93
- const devOutput = await this.claudeService.spawnClaude(devPrompt, state.worktree_path, `dev:${featureId}`, { onToolUse });
94
- this.log('PIPELINE', `Developer Claude completed for ${featureId}`);
103
+ } catch (err) {
104
+ this.log('PIPELINE', `WARN: Failed to move ${featureId} to in_progress: ${err.message}`);
105
+ }
95
106
 
96
- // Capture work summary from dev output and git diff --stat
97
- await this.captureWorkSummary(featureId, state, cycle, devOutput);
98
- } catch (err) {
99
- this.log('PIPELINE', `Developer Claude FAILED for ${featureId}: ${err.message}`);
100
- state.status = 'failed';
101
- state.error = `Developer failed: ${err.message}`;
107
+ // 2. Run developer
108
+ state.status = cycle === 1 ? 'developing' : 'fixing_review';
109
+ state.tasks = { completed: 0, total: 0, items: [] };
110
+ if (!state.stageStats) state.stageStats = emptyAllStageStats();
111
+ state.stageStats.in_progress = emptyStageStats();
112
+ state.toolCalls = state.stageStats.in_progress.toolCalls;
113
+ this.log('PIPELINE', `Status → ${state.status}`);
102
114
  this.persistState(featureId);
103
- return;
104
- }
115
+ try {
116
+ const devPrompt = await this.promptBuilder.buildDeveloperPrompt(featureId, cycle, previousReviewNotes, debuggerFindings, state.projectId);
117
+ if (cycle === 1) debuggerFindings = null;
118
+ this.log('PIPELINE', `Spawning developer Claude for ${featureId}...`);
119
+ const onToolUse = (toolName, input) => {
120
+ if (toolName === 'TodoWrite' && Array.isArray(input.todos)) {
121
+ state.tasks.items = input.todos.map(t => ({ name: t.content || t.activeForm || '', status: t.status || 'pending' }));
122
+ state.tasks.total = state.tasks.items.length;
123
+ state.tasks.completed = state.tasks.items.filter(t => t.status === 'completed').length;
124
+ }
125
+ state.toolCalls.total += 1;
126
+ const counterKey = TOOL_CALL_COUNTERS[toolName];
127
+ if (counterKey) {
128
+ state.toolCalls[counterKey] += 1;
129
+ }
130
+ this.persistState(featureId);
131
+ };
132
+ const onResult = (meta) => {
133
+ const stage = state.stageStats.in_progress;
134
+ stage.usage = meta.usage;
135
+ stage.stopReason = meta.stopReason;
136
+ stage.numTurns = meta.numTurns;
137
+ stage.costUsd = meta.costUsd;
138
+ stage.model = meta.model;
139
+ stage.durationMs = meta.durationMs;
140
+ if (meta.contextWindow) stage.contextWindow = meta.contextWindow;
141
+ this.persistState(featureId);
142
+ };
143
+ const onTurnUsage = (usage) => {
144
+ state.stageStats.in_progress.lastTurnUsage = usage;
145
+ };
146
+ const devOutput = await this.claudeService.spawnClaude(devPrompt, state.worktree_path, `dev:${featureId}`, { onToolUse, onResult, onTurnUsage });
147
+ this.log('PIPELINE', `Developer Claude completed for ${featureId}`);
148
+
149
+ // Capture work summary from dev output and git diff --stat
150
+ await this.captureWorkSummary(featureId, state, cycle, devOutput);
151
+ } catch (err) {
152
+ this.log('PIPELINE', `Developer Claude FAILED for ${featureId}: ${err.message}`);
153
+ state.status = 'failed';
154
+ state.error = `Developer failed: ${err.message}`;
155
+ this.persistState(featureId);
156
+ return;
157
+ }
105
158
 
106
- // 1b. Commit any uncommitted changes in the worktree
107
- try {
108
- await this.gitWorkflow.commitChanges(featureId, state.worktree_path);
109
- } catch (commitErr) {
110
- this.log('PIPELINE', `WARN: Auto-commit failed for ${featureId}: ${commitErr.message}`);
111
- }
159
+ // 1b. Commit any uncommitted changes in the worktree
160
+ try {
161
+ await this.getGitWorkflow(featureId).commitChanges(featureId, state.worktree_path);
162
+ } catch (commitErr) {
163
+ this.log('PIPELINE', `WARN: Auto-commit failed for ${featureId}: ${commitErr.message}`);
164
+ }
112
165
 
113
- // 1c. Rebase feature branch onto current main
114
- await this.gitWorkflow.rebaseBranch(featureId, state.worktree_path);
166
+ // 1c. Rebase feature branch onto current main
167
+ await this.getGitWorkflow(featureId).rebaseBranch(featureId, state.worktree_path);
115
168
 
116
- // 2. Pipeline moves card to in_review
117
- this.log('PIPELINE', `Moving ${featureId} to in_review...`);
118
- try {
119
- const reviewEntry = await this.kanban.getKanbanEntry(featureId);
120
- if (reviewEntry) {
121
- reviewEntry.column = 'in_review';
122
- reviewEntry.moved_at = new Date().toISOString();
123
- await this.kanban.saveKanbanEntry(featureId, reviewEntry);
124
- this.log('PIPELINE', `Moved ${featureId} → in_review`);
169
+ // Move card to in_review
170
+ this.log('PIPELINE', `Moving ${featureId} to in_review...`);
171
+ try {
172
+ const reviewEntry = await this.kanban.getKanbanEntry(featureId);
173
+ if (reviewEntry) {
174
+ reviewEntry.column = 'in_review';
175
+ reviewEntry.moved_at = new Date().toISOString();
176
+ await this.kanban.saveKanbanEntry(featureId, reviewEntry);
177
+ this.log('PIPELINE', `Moved ${featureId} → in_review`);
178
+ }
179
+ } catch (err) {
180
+ this.log('PIPELINE', `WARN: Failed to move ${featureId} to in_review: ${err.message}`);
125
181
  }
126
- } catch (err) {
127
- this.log('PIPELINE', `WARN: Failed to move ${featureId} to in_review: ${err.message}`);
128
182
  }
129
183
 
130
184
  // 3. Run reviewer
131
185
  state.status = 'reviewing';
186
+ if (!state.stageStats) state.stageStats = emptyAllStageStats();
187
+ state.stageStats.in_review = emptyStageStats();
132
188
  this.log('PIPELINE', `Status → reviewing`);
133
189
  this.persistState(featureId);
134
190
  try {
135
191
  const revPrompt = await this.promptBuilder.buildReviewerPrompt(featureId, state.projectId);
136
192
  this.log('PIPELINE', `Spawning reviewer Claude for ${featureId}...`);
137
- await this.claudeService.spawnClaude(revPrompt, state.worktree_path, `review:${featureId}`);
193
+ const revToolCalls = state.stageStats.in_review.toolCalls;
194
+ const revOnToolUse = (toolName, _input) => {
195
+ revToolCalls.total += 1;
196
+ const counterKey = TOOL_CALL_COUNTERS[toolName];
197
+ if (counterKey) {
198
+ revToolCalls[counterKey] += 1;
199
+ }
200
+ this.persistState(featureId);
201
+ };
202
+ const revOnResult = (meta) => {
203
+ const stage = state.stageStats.in_review;
204
+ stage.usage = meta.usage;
205
+ stage.stopReason = meta.stopReason;
206
+ stage.numTurns = meta.numTurns;
207
+ stage.costUsd = meta.costUsd;
208
+ stage.model = meta.model;
209
+ stage.durationMs = meta.durationMs;
210
+ if (meta.contextWindow) stage.contextWindow = meta.contextWindow;
211
+ this.persistState(featureId);
212
+ };
213
+ const revOnTurnUsage = (usage) => {
214
+ state.stageStats.in_review.lastTurnUsage = usage;
215
+ };
216
+ await this.claudeService.spawnClaude(revPrompt, state.worktree_path, `review:${featureId}`, { onToolUse: revOnToolUse, onResult: revOnResult, onTurnUsage: revOnTurnUsage });
138
217
  this.log('PIPELINE', `Reviewer Claude completed for ${featureId}`);
139
218
  } catch (err) {
140
219
  this.log('PIPELINE', `Reviewer Claude FAILED for ${featureId}: ${err.message}`);
@@ -192,6 +271,110 @@ export class Pipeline {
192
271
  await this.handleBlock(featureId, state);
193
272
  };
194
273
 
274
+ resume = async (featureId) => {
275
+ this.log('API', `POST /api/kanban/${featureId}/resume`);
276
+
277
+ // Reject if pipeline is already actively running
278
+ if (this.pipelines.has(featureId)) {
279
+ const existing = this.pipelines.get(featureId);
280
+ if (!['completed', 'blocked', 'failed', 'interrupted'].includes(existing.status)) {
281
+ this.log('RESUME', `REJECT: pipeline already running for ${featureId} (status=${existing.status})`);
282
+ return { error: `Pipeline already running for ${featureId}`, status: 409 };
283
+ }
284
+ }
285
+
286
+ const entry = await this.kanban.getKanbanEntry(featureId);
287
+ if (!entry) {
288
+ this.log('RESUME', `REJECT: ${featureId} not found in kanban`);
289
+ return { error: `Feature "${featureId}" not found in kanban`, status: 404 };
290
+ }
291
+
292
+ if (!['in_progress', 'in_review'].includes(entry.column)) {
293
+ this.log('RESUME', `REJECT: ${featureId} not in in_progress or in_review (is in ${entry.column})`);
294
+ return { error: `Feature must be in "in_progress" or "in_review" column to resume (currently "${entry.column}")`, status: 400 };
295
+ }
296
+
297
+ // Check if worktree exists (try project workspace first, then global)
298
+ let worktreePath = join(this.paths.worktreesDir, featureId);
299
+ if (!existsSync(worktreePath)) {
300
+ this.log('RESUME', `Worktree missing for ${featureId} — falling back to fresh start`);
301
+ // Move card back to todo so start() validation passes
302
+ entry.column = 'todo';
303
+ entry.moved_at = new Date().toISOString();
304
+ await this.kanban.saveKanbanEntry(featureId, entry);
305
+ return await this.start(featureId);
306
+ }
307
+
308
+ // Load persisted state for cycle number
309
+ let startCycle = 1;
310
+ if (this.stateStore) {
311
+ try {
312
+ const dbState = await this.stateStore.load(featureId);
313
+ if (dbState && dbState.cycle) {
314
+ startCycle = dbState.cycle;
315
+ this.log('RESUME', `Resuming ${featureId} from cycle ${startCycle} (from DB state)`);
316
+ }
317
+ } catch (err) {
318
+ this.log('RESUME', `WARN: Failed to load DB state for ${featureId}: ${err.message} — using cycle 1`);
319
+ }
320
+ }
321
+
322
+ // If card is in_review, the developer step already completed — skip it
323
+ const skipDevForFirstCycle = entry.column === 'in_review';
324
+ this.log('RESUME', `${featureId}: column=${entry.column} startCycle=${startCycle} skipDev=${skipDevForFirstCycle}`);
325
+
326
+ // Look up projectId
327
+ let projectId = null;
328
+ if (this.getNode) {
329
+ try {
330
+ const node = await this.getNode(featureId);
331
+ if (node) projectId = node.project_id || null;
332
+ } catch {}
333
+ }
334
+
335
+ // Resolve project workspace for resume
336
+ if (projectId && this.resolveProjectWorkspace) {
337
+ try {
338
+ const workspace = await this.resolveProjectWorkspace(projectId);
339
+ if (workspace) {
340
+ this.featureWorkflows.set(featureId, workspace.gitWorkflow);
341
+ if (workspace.afterMerge) {
342
+ this.featureAfterMerge.set(featureId, workspace.afterMerge);
343
+ }
344
+ this.log('RESUME', `Using project workspace for ${featureId} (project ${projectId})`);
345
+ }
346
+ } catch (err) {
347
+ this.log('RESUME', `WARN: Failed to resolve project workspace: ${err.message} — using default`);
348
+ }
349
+ }
350
+
351
+ const stageStats = emptyAllStageStats();
352
+ const initialStage = skipDevForFirstCycle ? 'in_review' : 'in_progress';
353
+ const state = {
354
+ status: skipDevForFirstCycle ? 'reviewing' : 'developing',
355
+ cycle: startCycle,
356
+ worktree_path: worktreePath,
357
+ process: null,
358
+ error: null,
359
+ tasks: { completed: 0, total: 0, items: [] },
360
+ toolCalls: stageStats[initialStage].toolCalls,
361
+ workSummaries: [],
362
+ projectId,
363
+ stageStats,
364
+ };
365
+ this.pipelines.set(featureId, state);
366
+ this.persistState(featureId);
367
+ this.log('RESUME', `Launching async pipeline resume for ${featureId}...`);
368
+
369
+ this.run(featureId, { startCycle, skipDevForFirstCycle }).catch(err => {
370
+ this.log('RESUME', `Pipeline UNCAUGHT ERROR for ${featureId}: ${err.message}`);
371
+ const s = this.pipelines.get(featureId);
372
+ if (s) { s.status = 'failed'; s.error = err.message; this.persistState(featureId); }
373
+ });
374
+
375
+ return { resumed: true, feature_id: featureId, status: 200 };
376
+ };
377
+
195
378
  /** Capture work summary after a developer cycle completes. */
196
379
  captureWorkSummary = async (featureId, state, cycle, devOutput) => {
197
380
  if (!this.workSummaryParser) return;
@@ -260,6 +443,7 @@ export class Pipeline {
260
443
  };
261
444
 
262
445
  handleMerge = async (featureId, state) => {
446
+ const gw = this.getGitWorkflow(featureId);
263
447
  state.status = 'merging';
264
448
  this.log('PIPELINE', `Status → merging`);
265
449
  this.persistState(featureId);
@@ -267,7 +451,7 @@ export class Pipeline {
267
451
  // Step 1: Stash any local changes on the parent branch before merging
268
452
  let stashed = false;
269
453
  try {
270
- stashed = await this.gitWorkflow.stash();
454
+ stashed = await gw.stash();
271
455
  } catch (stashErr) {
272
456
  this.log('PIPELINE', `Stash failed (non-fatal): ${stashErr.message}`);
273
457
  }
@@ -275,7 +459,7 @@ export class Pipeline {
275
459
  // Step 2: Merge the feature branch
276
460
  let mergeSucceeded = false;
277
461
  try {
278
- await this.gitWorkflow.mergeBranch(featureId);
462
+ await gw.mergeBranch(featureId);
279
463
  mergeSucceeded = true;
280
464
  } catch (mergeErr) {
281
465
  this.log('PIPELINE', `Merge FAILED: ${mergeErr.message} — attempting fix`);
@@ -283,7 +467,7 @@ export class Pipeline {
283
467
  this.log('PIPELINE', `Status → fixing_merge`);
284
468
  this.persistState(featureId);
285
469
  try {
286
- const fixed = await this.gitWorkflow.fixMergeConflicts(featureId);
470
+ const fixed = await gw.fixMergeConflicts(featureId);
287
471
  if (fixed) {
288
472
  this.log('PIPELINE', `Merge fix succeeded — branch verified as merged`);
289
473
  mergeSucceeded = true;
@@ -304,7 +488,7 @@ export class Pipeline {
304
488
  // Step 3: Pop stash to restore local changes
305
489
  if (stashed) {
306
490
  try {
307
- await this.gitWorkflow.unstash();
491
+ await gw.unstash();
308
492
  } catch (popErr) {
309
493
  this.log('PIPELINE', `Stash pop failed — changes remain in stash: ${popErr.message}`);
310
494
  }
@@ -313,15 +497,32 @@ export class Pipeline {
313
497
  // Step 4: Cleanup worktree + branch
314
498
  if (mergeSucceeded) {
315
499
  this.log('PIPELINE', `Cleaning up worktree and branch for ${featureId}...`);
316
- await this.gitWorkflow.removeWorktree(state.worktree_path);
317
- await this.gitWorkflow.deleteBranch(featureId);
500
+ await gw.removeWorktree(state.worktree_path);
501
+ await gw.deleteBranch(featureId);
502
+
503
+ // Step 5: Push to remote after merge (if project has a connected repo)
504
+ const afterMerge = this.featureAfterMerge.get(featureId);
505
+ if (afterMerge) {
506
+ try {
507
+ await afterMerge();
508
+ this.log('PIPELINE', `Post-merge push completed for ${featureId}`);
509
+ } catch (pushErr) {
510
+ this.log('PIPELINE', `Post-merge push FAILED (non-fatal): ${pushErr.message}`);
511
+ }
512
+ }
513
+
318
514
  state.status = 'completed';
319
515
  this.log('PIPELINE', `=== Pipeline COMPLETED for ${featureId} ===`);
320
516
  this.persistState(featureId);
517
+
518
+ // Cleanup per-feature state
519
+ this.featureWorkflows.delete(featureId);
520
+ this.featureAfterMerge.delete(featureId);
321
521
  }
322
522
  };
323
523
 
324
524
  handleBlock = async (featureId, state) => {
525
+ const gw = this.getGitWorkflow(featureId);
325
526
  this.log('PIPELINE', `=== All 3 cycles exhausted for ${featureId} — BLOCKING ===`);
326
527
  try {
327
528
  const blockEntry = await this.kanban.getKanbanEntry(featureId);
@@ -331,14 +532,18 @@ export class Pipeline {
331
532
  this.log('PIPELINE', `Set dev_blocked=true for ${featureId}`);
332
533
  }
333
534
  this.log('PIPELINE', `Cleaning up worktree for ${featureId}...`);
334
- await this.gitWorkflow.removeWorktree(state.worktree_path);
335
- await this.gitWorkflow.deleteBranch(featureId, true);
535
+ await gw.removeWorktree(state.worktree_path);
536
+ await gw.deleteBranch(featureId, true);
336
537
  } catch (err) {
337
538
  this.log('PIPELINE', `Block/cleanup error (non-fatal): ${err.message}`);
338
539
  }
339
540
  state.status = 'blocked';
340
541
  this.log('PIPELINE', `=== Pipeline BLOCKED for ${featureId} ===`);
341
542
  this.persistState(featureId);
543
+
544
+ // Cleanup per-feature state
545
+ this.featureWorkflows.delete(featureId);
546
+ this.featureAfterMerge.delete(featureId);
342
547
  };
343
548
 
344
549
  start = async (featureId) => {
@@ -372,10 +577,39 @@ export class Pipeline {
372
577
  return { error: `Feature "${featureId}" is dev_blocked. Unblock it first.`, status: 400 };
373
578
  }
374
579
 
580
+ // Look up the projectId from the feature node
581
+ let projectId = null;
582
+ if (this.getNode) {
583
+ try {
584
+ const node = await this.getNode(featureId);
585
+ if (node) projectId = node.project_id || null;
586
+ } catch {}
587
+ }
588
+
589
+ // Resolve project workspace: creates a project-scoped GitWorkflow if configured
590
+ let effectiveGitWorkflow = this.gitWorkflow;
591
+ if (projectId && this.resolveProjectWorkspace) {
592
+ try {
593
+ const workspace = await this.resolveProjectWorkspace(projectId);
594
+ if (workspace) {
595
+ effectiveGitWorkflow = workspace.gitWorkflow;
596
+ this.featureWorkflows.set(featureId, workspace.gitWorkflow);
597
+ if (workspace.afterMerge) {
598
+ this.featureAfterMerge.set(featureId, workspace.afterMerge);
599
+ }
600
+ this.log('DEVELOP', `Using project workspace for ${featureId} (project ${projectId})`);
601
+ }
602
+ } catch (err) {
603
+ this.log('DEVELOP', `WARN: Failed to resolve project workspace: ${err.message} — using default`);
604
+ }
605
+ }
606
+
375
607
  // Pre-flight: reject if working tree is dirty to avoid stash conflicts on merge
376
- const dirtyFiles = await this.gitWorkflow.getDirtyFiles();
608
+ const dirtyFiles = await effectiveGitWorkflow.getDirtyFiles();
377
609
  if (dirtyFiles.length > 0) {
378
610
  this.log('DEVELOP', `REJECT: working tree has ${dirtyFiles.length} uncommitted change(s)`);
611
+ this.featureWorkflows.delete(featureId);
612
+ this.featureAfterMerge.delete(featureId);
379
613
  return { error: `Working tree has uncommitted changes. Commit or stash them before starting a pipeline.\n${dirtyFiles.join('\n')}`, status: 400 };
380
614
  }
381
615
 
@@ -385,29 +619,32 @@ export class Pipeline {
385
619
  await this.kanban.saveKanbanEntry(featureId, entry);
386
620
  this.log('DEVELOP', `Moved ${featureId} to in_progress`);
387
621
 
622
+ // Pull latest before creating worktree (ensure up-to-date base)
623
+ if (effectiveGitWorkflow !== this.gitWorkflow) {
624
+ try {
625
+ await effectiveGitWorkflow.pullDefaultBranch();
626
+ } catch (pullErr) {
627
+ this.log('DEVELOP', `WARN: Pull before worktree failed: ${pullErr.message}`);
628
+ }
629
+ }
630
+
388
631
  // Create worktree
389
632
  let worktreePath;
390
633
  try {
391
- worktreePath = await this.gitWorkflow.createWorktree(featureId);
634
+ worktreePath = await effectiveGitWorkflow.createWorktree(featureId);
392
635
  } catch (err) {
393
636
  this.log('DEVELOP', `Worktree creation FAILED: ${err.message}`);
394
637
  // Revert kanban change
395
638
  entry.column = 'todo';
396
639
  await this.kanban.saveKanbanEntry(featureId, entry);
397
640
  this.log('DEVELOP', `Reverted ${featureId} back to todo`);
641
+ this.featureWorkflows.delete(featureId);
642
+ this.featureAfterMerge.delete(featureId);
398
643
  return { error: `Failed to create worktree: ${err.message}`, status: 500 };
399
644
  }
400
645
 
401
- // Look up the projectId from the feature node
402
- let projectId = null;
403
- if (this.getNode) {
404
- try {
405
- const node = await this.getNode(featureId);
406
- if (node) projectId = node.project_id || null;
407
- } catch {}
408
- }
409
-
410
- const state = { status: 'developing', cycle: 1, worktree_path: worktreePath, process: null, error: null, tasks: { completed: 0, total: 0, items: [] }, toolCalls: { total: 0, write: 0, edit: 0, read: 0, bash: 0 }, workSummaries: [], projectId };
646
+ const stageStats = emptyAllStageStats();
647
+ const state = { status: 'developing', cycle: 1, worktree_path: worktreePath, process: null, error: null, tasks: { completed: 0, total: 0, items: [] }, toolCalls: stageStats.in_progress.toolCalls, workSummaries: [], projectId, stageStats };
411
648
  this.pipelines.set(featureId, state);
412
649
  this.persistState(featureId);
413
650
  this.log('DEVELOP', `Pipeline state initialized for ${featureId}`);
@@ -460,6 +697,9 @@ export class Pipeline {
460
697
  if (state.workSummaries && state.workSummaries.length > 0) {
461
698
  result.workSummaries = state.workSummaries;
462
699
  }
700
+ if (state.stageStats) {
701
+ result.stageStats = state.stageStats;
702
+ }
463
703
  return result;
464
704
  };
465
705