@assistkick/create 1.2.0 → 1.4.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.
- package/package.json +2 -1
- package/templates/assistkick-product-system/GITHUB_APP_SETUP.md +88 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +231 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +4 -4
- package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +49 -2
- package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +82 -0
- package/templates/assistkick-product-system/packages/backend/src/server.ts +19 -6
- package/templates/assistkick-product-system/packages/backend/src/services/github_app_service.ts +146 -0
- package/templates/assistkick-product-system/packages/backend/src/services/init.ts +69 -2
- package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +71 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +87 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +194 -0
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -17
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +114 -39
- package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +28 -14
- package/templates/assistkick-product-system/packages/frontend/src/App.tsx +1 -1
- package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +151 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/GitRepoModal.tsx +352 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +208 -95
- package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +17 -1
- package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +238 -105
- package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +15 -13
- package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +1 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +4 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/dashboard.tsx +22 -4
- package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +486 -38
- package/templates/assistkick-product-system/packages/shared/db/migrations/0001_vengeful_wallop.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0002_greedy_excalibur.sql +4 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0003_lonely_cyclops.sql +17 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0001_snapshot.json +826 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0002_snapshot.json +854 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0003_snapshot.json +862 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +21 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +10 -3
- package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +54 -1
- package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +25 -0
- package/templates/assistkick-product-system/packages/shared/lib/pipeline-state-store.ts +4 -0
- package/templates/assistkick-product-system/packages/shared/lib/pipeline.ts +329 -89
- package/templates/assistkick-product-system/packages/shared/lib/pipeline_orchestrator.ts +186 -0
- package/templates/assistkick-product-system/packages/shared/lib/session.ts +10 -6
- package/templates/assistkick-product-system/packages/shared/tools/db_explorer.ts +275 -0
- package/templates/assistkick-product-system/packages/shared/tools/end_session.ts +2 -2
- package/templates/assistkick-product-system/packages/shared/tools/get_kanban.ts +2 -1
- package/templates/assistkick-product-system/packages/shared/tools/move_card.ts +3 -2
- package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -2
- package/templates/assistkick-product-system/tests/kanban.test.ts +1 -1
- package/templates/assistkick-product-system/tests/pipeline_stats_all_cards.test.ts +1 -1
- package/templates/assistkick-product-system/tests/web_terminal.test.ts +189 -150
- package/templates/skills/assistkick-bootstrap/SKILL.md +33 -25
- package/templates/skills/assistkick-code-reviewer/SKILL.md +23 -15
- package/templates/skills/assistkick-db-explorer/SKILL.md +86 -0
- package/templates/skills/assistkick-debugger/SKILL.md +30 -22
- package/templates/skills/assistkick-developer/SKILL.md +37 -29
- 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:
|
|
51
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
state.
|
|
101
|
-
state.
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
166
|
+
// 1c. Rebase feature branch onto current main
|
|
167
|
+
await this.getGitWorkflow(featureId).rebaseBranch(featureId, state.worktree_path);
|
|
115
168
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
317
|
-
await
|
|
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
|
|
335
|
-
await
|
|
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
|
|
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
|
|
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
|
-
|
|
402
|
-
|
|
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
|
|