@coralai/sps-cli 0.32.0 → 0.34.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 (97) hide show
  1. package/README.md +103 -6
  2. package/dist/commands/agentCommand.js +1 -1
  3. package/dist/commands/agentCommand.js.map +1 -1
  4. package/dist/commands/cardDashboard.d.ts.map +1 -1
  5. package/dist/commands/cardDashboard.js +57 -32
  6. package/dist/commands/cardDashboard.js.map +1 -1
  7. package/dist/commands/pipelineTick.d.ts.map +1 -1
  8. package/dist/commands/pipelineTick.js +12 -6
  9. package/dist/commands/pipelineTick.js.map +1 -1
  10. package/dist/commands/qaTick.d.ts.map +1 -1
  11. package/dist/commands/qaTick.js +13 -6
  12. package/dist/commands/qaTick.js.map +1 -1
  13. package/dist/commands/setup.d.ts +7 -0
  14. package/dist/commands/setup.d.ts.map +1 -1
  15. package/dist/commands/setup.js +86 -1
  16. package/dist/commands/setup.js.map +1 -1
  17. package/dist/commands/tick.d.ts.map +1 -1
  18. package/dist/commands/tick.js +27 -19
  19. package/dist/commands/tick.js.map +1 -1
  20. package/dist/commands/workerDashboard.js +2 -2
  21. package/dist/commands/workerDashboard.js.map +1 -1
  22. package/dist/commands/workerLaunch.d.ts.map +1 -1
  23. package/dist/commands/workerLaunch.js +11 -6
  24. package/dist/commands/workerLaunch.js.map +1 -1
  25. package/dist/commands/workerPs.d.ts +11 -0
  26. package/dist/commands/workerPs.d.ts.map +1 -0
  27. package/dist/commands/workerPs.js +228 -0
  28. package/dist/commands/workerPs.js.map +1 -0
  29. package/dist/core/config.d.ts +4 -9
  30. package/dist/core/config.d.ts.map +1 -1
  31. package/dist/core/config.js +4 -18
  32. package/dist/core/config.js.map +1 -1
  33. package/dist/core/config.test.js +2 -19
  34. package/dist/core/config.test.js.map +1 -1
  35. package/dist/core/memory.d.ts +86 -0
  36. package/dist/core/memory.d.ts.map +1 -0
  37. package/dist/core/memory.js +415 -0
  38. package/dist/core/memory.js.map +1 -0
  39. package/dist/core/projectPipelineAdapter.d.ts +16 -6
  40. package/dist/core/projectPipelineAdapter.d.ts.map +1 -1
  41. package/dist/core/projectPipelineAdapter.js +94 -30
  42. package/dist/core/projectPipelineAdapter.js.map +1 -1
  43. package/dist/core/sessionLiveness.d.ts.map +1 -1
  44. package/dist/core/sessionLiveness.js +1 -2
  45. package/dist/core/sessionLiveness.js.map +1 -1
  46. package/dist/core/state.d.ts +2 -2
  47. package/dist/core/state.d.ts.map +1 -1
  48. package/dist/engines/CloseoutEngine.js +1 -1
  49. package/dist/engines/CloseoutEngine.js.map +1 -1
  50. package/dist/engines/EventHandler.d.ts.map +1 -1
  51. package/dist/engines/EventHandler.js +6 -9
  52. package/dist/engines/EventHandler.js.map +1 -1
  53. package/dist/engines/ExecutionEngine.d.ts.map +1 -1
  54. package/dist/engines/ExecutionEngine.js +6 -18
  55. package/dist/engines/ExecutionEngine.js.map +1 -1
  56. package/dist/engines/MonitorEngine.d.ts.map +1 -1
  57. package/dist/engines/MonitorEngine.js +11 -7
  58. package/dist/engines/MonitorEngine.js.map +1 -1
  59. package/dist/engines/SchedulerEngine.js +1 -1
  60. package/dist/engines/SchedulerEngine.js.map +1 -1
  61. package/dist/engines/StageEngine.d.ts +80 -0
  62. package/dist/engines/StageEngine.d.ts.map +1 -0
  63. package/dist/engines/StageEngine.js +1101 -0
  64. package/dist/engines/StageEngine.js.map +1 -0
  65. package/dist/engines/engine-pipeline-adapter.test.js +26 -21
  66. package/dist/engines/engine-pipeline-adapter.test.js.map +1 -1
  67. package/dist/main.js +172 -7
  68. package/dist/main.js.map +1 -1
  69. package/dist/manager/integration-queue.d.ts +1 -1
  70. package/dist/manager/integration-queue.d.ts.map +1 -1
  71. package/dist/manager/integration-queue.test.js +1 -1
  72. package/dist/manager/integration-queue.test.js.map +1 -1
  73. package/dist/manager/pm-client.js +1 -1
  74. package/dist/manager/runtime-coordinator.js +1 -1
  75. package/dist/manager/runtime-coordinator.js.map +1 -1
  76. package/dist/manager/supervisor.d.ts +6 -10
  77. package/dist/manager/supervisor.d.ts.map +1 -1
  78. package/dist/manager/supervisor.js +6 -102
  79. package/dist/manager/supervisor.js.map +1 -1
  80. package/dist/manager/supervisor.test.js +7 -51
  81. package/dist/manager/supervisor.test.js.map +1 -1
  82. package/dist/manager/worker-manager-impl.d.ts.map +1 -1
  83. package/dist/manager/worker-manager-impl.js +22 -43
  84. package/dist/manager/worker-manager-impl.js.map +1 -1
  85. package/dist/manager/worker-manager-impl.test.js +20 -139
  86. package/dist/manager/worker-manager-impl.test.js.map +1 -1
  87. package/dist/manager/worker-manager.d.ts +2 -2
  88. package/dist/manager/worker-manager.d.ts.map +1 -1
  89. package/dist/providers/MarkdownTaskBackend.d.ts +6 -1
  90. package/dist/providers/MarkdownTaskBackend.d.ts.map +1 -1
  91. package/dist/providers/MarkdownTaskBackend.js +50 -24
  92. package/dist/providers/MarkdownTaskBackend.js.map +1 -1
  93. package/dist/providers/registry.d.ts +1 -1
  94. package/dist/providers/registry.d.ts.map +1 -1
  95. package/dist/providers/registry.js +2 -2
  96. package/dist/providers/registry.js.map +1 -1
  97. package/package.json +1 -1
@@ -0,0 +1,1101 @@
1
+ import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { resolve } from 'node:path';
4
+ import { RuntimeStore } from '../core/runtimeStore.js';
5
+ import { resolveGitlabProjectId, resolveWorkflowTransport } from '../core/config.js';
6
+ import { resolveWorktreePath } from '../core/paths.js';
7
+ import { readQueue } from '../core/queue.js';
8
+ import { buildPhasePrompt } from '../core/taskPrompts.js';
9
+ import { Logger } from '../core/logger.js';
10
+ import { IntegrationQueue } from '../manager/integration-queue.js';
11
+ import { branchPushed, branchCommitsAhead } from '../providers/outputParser.js';
12
+ import { buildFullMemoryContext, buildMemoryWriteInstructions } from '../core/memory.js';
13
+ const SKIP_LABELS = ['BLOCKED', 'NEEDS-FIX', 'CONFLICT', 'WAITING-CONFIRMATION', 'STALE-RUNTIME'];
14
+ const CLEANUP_LABELS = [...SKIP_LABELS, 'CLAIMED'];
15
+ /**
16
+ * StageEngine — generic engine that handles any pipeline stage.
17
+ *
18
+ * Replaces both ExecutionEngine and CloseoutEngine. Behavior is driven by
19
+ * the StageDefinition from YAML config + positional flags (isFirstStage / isLastStage).
20
+ *
21
+ * First stage: also handles prepare (branch + worktree creation, Backlog → Ready).
22
+ * Last stage: also handles release (worktree cleanup, resource release).
23
+ * Any stage with queue: 'fifo' uses IntegrationQueue for serialization.
24
+ */
25
+ export class StageEngine {
26
+ ctx;
27
+ stage;
28
+ stageIndex;
29
+ totalStages;
30
+ taskBackend;
31
+ repoBackend;
32
+ workerManager;
33
+ pipelineAdapter;
34
+ notifier;
35
+ log;
36
+ runtimeStore;
37
+ constructor(ctx, stage, stageIndex, totalStages, taskBackend, repoBackend, workerManager, pipelineAdapter, notifier) {
38
+ this.ctx = ctx;
39
+ this.stage = stage;
40
+ this.stageIndex = stageIndex;
41
+ this.totalStages = totalStages;
42
+ this.taskBackend = taskBackend;
43
+ this.repoBackend = repoBackend;
44
+ this.workerManager = workerManager;
45
+ this.pipelineAdapter = pipelineAdapter;
46
+ this.notifier = notifier;
47
+ this.log = new Logger(`stage-${stage.name}`, ctx.projectName, ctx.paths.logsDir);
48
+ this.runtimeStore = new RuntimeStore(ctx);
49
+ }
50
+ /** Stage name from YAML */
51
+ get name() { return this.stage.name; }
52
+ /** Whether this is the first stage (responsible for prepare: branch + worktree) */
53
+ get isFirstStage() { return this.stageIndex === 0; }
54
+ /** Whether this is the last stage (responsible for release: worktree cleanup) */
55
+ get isLastStage() { return this.stageIndex === this.totalStages - 1; }
56
+ /** Whether this stage uses FIFO queue for serialization */
57
+ get usesQueue() { return this.stage.queue === 'fifo'; }
58
+ async tick(opts = {}) {
59
+ const actions = [];
60
+ const recommendedActions = [];
61
+ const result = {
62
+ project: this.ctx.projectName,
63
+ component: `stage-${this.stage.name}`,
64
+ status: 'ok',
65
+ exitCode: 0,
66
+ actions,
67
+ recommendedActions,
68
+ details: {},
69
+ };
70
+ let actionsThisTick = 0;
71
+ const maxActions = this.ctx.config.MAX_ACTIONS_PER_TICK;
72
+ try {
73
+ // ── First stage only: reconcile PM states + prepare backlog cards ──
74
+ if (this.isFirstStage) {
75
+ actions.push(...await this.reconcilePmStatesWithRuntime());
76
+ // 1. Check active cards for completion (free slots before launching)
77
+ const activeCards = await this.listRuntimeAwareActiveCards();
78
+ for (const card of activeCards) {
79
+ if (this.shouldSkip(card))
80
+ continue;
81
+ const checkResult = await this.checkActiveCard(card, opts);
82
+ if (checkResult)
83
+ actions.push(checkResult);
84
+ }
85
+ // 2. Prepare backlog cards (branch + worktree + move to ready)
86
+ const backlogCards = await this.taskBackend.listByState(this.pipelineAdapter.states.backlog);
87
+ const currentState = this.runtimeStore.readState();
88
+ const idleSlots = Object.values(currentState.workers).filter(w => w.status === 'idle').length;
89
+ const readyCards0 = await this.taskBackend.listByState(this.pipelineAdapter.states.ready);
90
+ const readyCount = readyCards0.filter(c => !this.shouldSkip(c)).length;
91
+ const prepareLimit = Math.max(0, idleSlots - readyCount);
92
+ let preparedThisTick = 0;
93
+ for (const card of backlogCards) {
94
+ if (preparedThisTick >= prepareLimit)
95
+ break;
96
+ await this.cleanAuxiliaryLabels(card);
97
+ if (this.shouldSkip(card)) {
98
+ actions.push({ action: 'skip', entity: `seq:${card.seq}`, result: 'skip', message: 'Has auxiliary state label' });
99
+ continue;
100
+ }
101
+ const prepareResult = await this.prepareCard(card, opts);
102
+ actions.push(prepareResult);
103
+ if (prepareResult.result === 'ok')
104
+ preparedThisTick++;
105
+ }
106
+ // 3. Launch ready cards (claim + context + worker + move to active)
107
+ let readyCards = await this.taskBackend.listByState(this.pipelineAdapter.states.ready);
108
+ const pipelineOrder = readQueue(this.ctx.paths.pipelineOrderFile);
109
+ if (pipelineOrder.length > 0) {
110
+ readyCards = readyCards.sort((a, b) => {
111
+ const aIdx = pipelineOrder.indexOf(parseInt(a.seq, 10));
112
+ const bIdx = pipelineOrder.indexOf(parseInt(b.seq, 10));
113
+ if (aIdx >= 0 && bIdx >= 0)
114
+ return aIdx - bIdx;
115
+ if (aIdx >= 0)
116
+ return -1;
117
+ if (bIdx >= 0)
118
+ return 1;
119
+ return parseInt(a.seq, 10) - parseInt(b.seq, 10);
120
+ });
121
+ }
122
+ const failedSlots = new Set();
123
+ for (const card of readyCards) {
124
+ if (actionsThisTick >= maxActions)
125
+ break;
126
+ if (this.shouldSkip(card)) {
127
+ actions.push({ action: 'skip', entity: `seq:${card.seq}`, result: 'skip', message: 'Has auxiliary state label' });
128
+ continue;
129
+ }
130
+ const launchResult = await this.launchWorker(card, opts, failedSlots);
131
+ actions.push(launchResult);
132
+ if (launchResult.result === 'ok')
133
+ actionsThisTick++;
134
+ }
135
+ }
136
+ else {
137
+ // ── Non-first stages: process trigger-state cards ──
138
+ const triggerCards = await this.taskBackend.listByState(this.stage.triggerState);
139
+ if (triggerCards.length === 0) {
140
+ this.log.info(`No ${this.stage.triggerState} cards to process`);
141
+ result.details = { reason: `no_${this.stage.name}_cards` };
142
+ }
143
+ else {
144
+ this.log.info(`Processing ${triggerCards.length} ${this.stage.triggerState} card(s)`);
145
+ for (const card of triggerCards) {
146
+ if (card.labels.includes('BLOCKED')) {
147
+ this.log.debug(`Skipping seq ${card.seq}: BLOCKED`);
148
+ actions.push({ action: 'skip', entity: `seq:${card.seq}`, result: 'skip', message: 'Card is BLOCKED' });
149
+ continue;
150
+ }
151
+ try {
152
+ await this.processStageCard(card, actions, recommendedActions);
153
+ }
154
+ catch (err) {
155
+ const msg = err instanceof Error ? err.message : String(err);
156
+ this.log.error(`Unexpected error processing seq ${card.seq}: ${msg}`);
157
+ actions.push({
158
+ action: `stage-${this.stage.name}`,
159
+ entity: `seq:${card.seq}`,
160
+ result: 'fail',
161
+ message: `Unexpected error: ${msg}`,
162
+ });
163
+ }
164
+ }
165
+ }
166
+ }
167
+ }
168
+ catch (err) {
169
+ const msg = err instanceof Error ? err.message : String(err);
170
+ this.log.error(`Stage ${this.stage.name} tick failed: ${msg}`);
171
+ result.status = 'fail';
172
+ result.exitCode = 1;
173
+ result.details = { error: msg };
174
+ }
175
+ // Last stage: always run worktree cleanup
176
+ if (this.isLastStage) {
177
+ await this.cleanupWorktrees(actions);
178
+ }
179
+ if (actions.some((a) => a.result === 'fail') && result.status === 'ok') {
180
+ result.status = this.isLastStage ? 'degraded' : 'fail';
181
+ result.exitCode = 1;
182
+ }
183
+ return result;
184
+ }
185
+ // ─── Non-first stage: process a card in trigger state ──────────
186
+ async processStageCard(card, actions, recommendedActions) {
187
+ const seq = card.seq;
188
+ const state = this.runtimeStore.readState();
189
+ const runtime = this.runtimeStore.getTask(seq, state);
190
+ const branchName = runtime.lease?.branch ||
191
+ runtime.evidence?.branch ||
192
+ this.buildBranchName(card);
193
+ const worktree = runtime.lease?.worktree ||
194
+ runtime.evidence?.worktree ||
195
+ resolveWorktreePath(this.ctx.projectName, seq, this.ctx.config.WORKTREE_DIR);
196
+ if (!worktree || !existsSync(worktree)) {
197
+ await this.markNeedsFix(seq, `${this.stage.name} task has no usable worktree`);
198
+ actions.push({
199
+ action: 'mark-needs-fix',
200
+ entity: `seq:${seq}`,
201
+ result: 'ok',
202
+ message: `No usable worktree for ${this.stage.name} task`,
203
+ });
204
+ return;
205
+ }
206
+ // For stages with fast-forward-merge completion: check if already merged
207
+ if (this.stage.completion === 'fast-forward-merge' && this.isMergedToBase(worktree, branchName)) {
208
+ this.log.info(`seq ${seq}: integration already complete, proceeding to release`);
209
+ await this.completeAndAdvance(card, actions);
210
+ return;
211
+ }
212
+ // Check if worker is already running for this card
213
+ const activeStatus = await this.inspectStageWorker(card, runtime.slotName, worktree, branchName, actions);
214
+ if (activeStatus === 'active' || activeStatus === 'waiting' || activeStatus === 'done') {
215
+ return;
216
+ }
217
+ if (activeStatus === 'failed') {
218
+ recommendedActions.push({
219
+ action: `Review ${this.stage.name} task seq:${seq}`,
220
+ reason: `${this.stage.name} worker exited without completing`,
221
+ severity: 'warning',
222
+ autoExecutable: false,
223
+ requiresConfirmation: true,
224
+ safeToRetry: true,
225
+ });
226
+ return;
227
+ }
228
+ // Start a new worker for this stage
229
+ await this.startStageWorker(card, runtime.slotName, worktree, branchName, actions);
230
+ }
231
+ async inspectStageWorker(card, slotName, worktree, branchName, actions) {
232
+ if (!slotName)
233
+ return 'idle';
234
+ const snapshots = this.workerManager.inspect({ taskId: String(card.seq) });
235
+ if (snapshots.length === 0)
236
+ return 'idle';
237
+ const snapshot = snapshots[0];
238
+ if (snapshot.state === 'idle')
239
+ return 'idle';
240
+ if (snapshot.state === 'waiting_input') {
241
+ this.log.info(`seq ${card.seq}: ${this.stage.name} worker waiting for input`);
242
+ actions.push({
243
+ action: `${this.stage.name}-waiting`,
244
+ entity: `seq:${card.seq}`,
245
+ result: 'skip',
246
+ message: `${this.stage.name} worker waiting_input`,
247
+ });
248
+ return 'waiting';
249
+ }
250
+ if (snapshot.state === 'needs_confirmation') {
251
+ this.log.warn(`seq ${card.seq}: ${this.stage.name} worker needs confirmation`);
252
+ actions.push({
253
+ action: `${this.stage.name}-waiting`,
254
+ entity: `seq:${card.seq}`,
255
+ result: 'skip',
256
+ message: `${this.stage.name} worker needs_confirmation`,
257
+ });
258
+ return 'waiting';
259
+ }
260
+ if (snapshot.state === 'starting' || snapshot.state === 'running') {
261
+ actions.push({
262
+ action: `${this.stage.name}-running`,
263
+ entity: `seq:${card.seq}`,
264
+ result: 'skip',
265
+ message: `${this.stage.name} worker ${snapshot.state}`,
266
+ });
267
+ return 'active';
268
+ }
269
+ if (snapshot.state === 'completed') {
270
+ // For fast-forward-merge: check if actually merged
271
+ if (this.stage.completion === 'fast-forward-merge') {
272
+ if (this.isMergedToBase(worktree, branchName)) {
273
+ await this.completeAndAdvance(card, actions);
274
+ return 'done';
275
+ }
276
+ }
277
+ else {
278
+ // Other completion strategies: worker completed = stage done
279
+ await this.completeAndAdvance(card, actions);
280
+ return 'done';
281
+ }
282
+ }
283
+ // 'failed' or 'completed' without expected completion
284
+ await this.releaseSlotForStage(card.seq, slotName);
285
+ await this.markNeedsFix(card.seq, `${this.stage.name} worker ${snapshot.state} before completion`);
286
+ actions.push({
287
+ action: 'mark-needs-fix',
288
+ entity: `seq:${card.seq}`,
289
+ result: 'ok',
290
+ message: `${this.stage.name} worker ${snapshot.state} before completion`,
291
+ });
292
+ return 'failed';
293
+ }
294
+ async startStageWorker(card, _preferredSlot, worktree, branchName, actions) {
295
+ const seq = card.seq;
296
+ const prompt = this.buildStagePrompt(card, worktree, branchName);
297
+ const workflowTransport = resolveWorkflowTransport(this.ctx.config);
298
+ const logsDir = this.ctx.paths.logsDir;
299
+ const runRequest = {
300
+ taskId: String(card.seq),
301
+ cardId: String(card.seq),
302
+ project: this.ctx.projectName,
303
+ phase: this.stage.name,
304
+ prompt,
305
+ cwd: worktree,
306
+ branch: branchName,
307
+ targetBranch: this.ctx.mergeBranch,
308
+ tool: (this.stage.agent || this.ctx.config.WORKER_TOOL),
309
+ transport: 'acp-sdk',
310
+ outputFile: resolve(logsDir, `${this.ctx.projectName}-${this.stage.name}-${card.seq}-${Date.now()}.jsonl`),
311
+ completionStrategy: this.stage.completion,
312
+ };
313
+ const response = await this.workerManager.run(runRequest);
314
+ if (!response.accepted) {
315
+ this.log.info(`seq ${seq}: WM rejected ${this.stage.name} run: ${response.rejectReason ?? 'unknown'}`);
316
+ actions.push({
317
+ action: `${this.stage.name}-launch`,
318
+ entity: `seq:${seq}`,
319
+ result: 'skip',
320
+ message: `WM rejected: ${response.rejectReason ?? 'unknown'}`,
321
+ });
322
+ return;
323
+ }
324
+ // Queued (fifo mode)
325
+ if (response.queued) {
326
+ this.log.info(`seq ${seq}: Queued for ${this.stage.name} (position=${response.queuePosition})`);
327
+ this.runtimeStore.updateState(`stage-${this.stage.name}-queue`, (draft) => {
328
+ if (draft.leases[seq]) {
329
+ draft.leases[seq].phase = 'merging';
330
+ draft.leases[seq].lastTransitionAt = new Date().toISOString();
331
+ }
332
+ });
333
+ actions.push({
334
+ action: `${this.stage.name}-launch`,
335
+ entity: `seq:${seq}`,
336
+ result: 'ok',
337
+ message: `Queued for ${this.stage.name} (position=${response.queuePosition})`,
338
+ });
339
+ return;
340
+ }
341
+ // PM claim (best-effort)
342
+ const slotName = response.slot;
343
+ try {
344
+ await this.taskBackend.claim(seq, slotName);
345
+ }
346
+ catch (err) {
347
+ this.log.warn(`seq ${seq}: PM claim for ${this.stage.name} worker failed: ${err instanceof Error ? err.message : err}`);
348
+ }
349
+ // Update runtime state
350
+ this.runtimeStore.updateState(`stage-${this.stage.name}-launch`, (draft) => {
351
+ draft.activeCards[seq] = {
352
+ seq: parseInt(seq, 10),
353
+ state: this.stage.activeState,
354
+ worker: slotName,
355
+ mrUrl: draft.activeCards[seq]?.mrUrl || null,
356
+ conflictDomains: draft.activeCards[seq]?.conflictDomains || [],
357
+ startedAt: draft.activeCards[seq]?.startedAt || new Date().toISOString(),
358
+ retryCount: draft.activeCards[seq]?.retryCount ?? draft.leases[seq]?.retryCount ?? 0,
359
+ };
360
+ draft.leases[seq] = {
361
+ seq: parseInt(seq, 10),
362
+ pmStateObserved: this.stage.activeState,
363
+ phase: this.stage.completion === 'fast-forward-merge' ? 'merging' : 'coding',
364
+ slot: slotName,
365
+ branch: branchName,
366
+ worktree,
367
+ sessionId: response.sessionId || null,
368
+ runId: null,
369
+ claimedAt: new Date().toISOString(),
370
+ retryCount: draft.leases[seq]?.retryCount ?? 0,
371
+ lastTransitionAt: new Date().toISOString(),
372
+ };
373
+ });
374
+ actions.push({
375
+ action: `${this.stage.name}-launch`,
376
+ entity: `seq:${seq}`,
377
+ result: 'ok',
378
+ message: `Started ${this.stage.name} worker on ${slotName}`,
379
+ });
380
+ this.logEvent(`${this.stage.name}-launch`, seq, 'ok', { worker: slotName });
381
+ }
382
+ // ─── Completion: advance card to next state or release ─────────
383
+ async completeAndAdvance(card, actions) {
384
+ if (this.isLastStage) {
385
+ await this.releaseAndDone(card, actions);
386
+ }
387
+ else {
388
+ // Move card to next state
389
+ const seq = card.seq;
390
+ try {
391
+ await this.taskBackend.move(seq, this.stage.onCompleteState);
392
+ this.log.ok(`seq ${seq}: ${this.stage.name} complete → ${this.stage.onCompleteState}`);
393
+ // Release the current slot so next stage can use it
394
+ const state = this.runtimeStore.readState();
395
+ const runtime = this.runtimeStore.getTask(seq, state);
396
+ if (runtime.slotName) {
397
+ await this.releaseSlotForStage(seq, runtime.slotName);
398
+ }
399
+ actions.push({
400
+ action: `${this.stage.name}-complete`,
401
+ entity: `seq:${seq}`,
402
+ result: 'ok',
403
+ message: `${this.stage.activeState} → ${this.stage.onCompleteState}`,
404
+ });
405
+ }
406
+ catch (err) {
407
+ const msg = err instanceof Error ? err.message : String(err);
408
+ this.log.error(`seq ${seq}: Failed to advance: ${msg}`);
409
+ actions.push({
410
+ action: `${this.stage.name}-complete`,
411
+ entity: `seq:${seq}`,
412
+ result: 'fail',
413
+ message: `Advance failed: ${msg}`,
414
+ });
415
+ }
416
+ }
417
+ }
418
+ /**
419
+ * Release resources after final stage completion.
420
+ * Order: Move Done → Release claim → Release slot → Stop worker → Mark worktree cleanup
421
+ */
422
+ async releaseAndDone(card, actions) {
423
+ const seq = card.seq;
424
+ const errors = [];
425
+ // Clean auxiliary labels
426
+ for (const label of this.pipelineAdapter.auxiliaryLabels) {
427
+ if (card.labels.includes(label)) {
428
+ try {
429
+ await this.taskBackend.removeLabel(seq, label);
430
+ }
431
+ catch { /* best effort */ }
432
+ }
433
+ }
434
+ // Step 1: Move card to Done
435
+ try {
436
+ await this.taskBackend.move(seq, this.pipelineAdapter.states.done);
437
+ this.log.ok(`seq ${seq}: Moved to ${this.pipelineAdapter.states.done}`);
438
+ }
439
+ catch (err) {
440
+ const msg = err instanceof Error ? err.message : String(err);
441
+ this.log.error(`seq ${seq}: Failed to move to Done: ${msg}`);
442
+ errors.push(`move-done: ${msg}`);
443
+ }
444
+ // Step 2: Release claim
445
+ try {
446
+ await this.taskBackend.releaseClaim(seq);
447
+ this.log.ok(`seq ${seq}: Claim released`);
448
+ }
449
+ catch (err) {
450
+ const msg = err instanceof Error ? err.message : String(err);
451
+ this.log.error(`seq ${seq}: Failed to release claim: ${msg}`);
452
+ errors.push(`release-claim: ${msg}`);
453
+ }
454
+ // Step 3: Release worker slot
455
+ const state = this.runtimeStore.readState();
456
+ const runtime = this.runtimeStore.getTask(seq, state);
457
+ const slotEntry = runtime.slotName && runtime.slot ? [runtime.slotName, runtime.slot] : null;
458
+ if (slotEntry) {
459
+ const [slotName] = slotEntry;
460
+ try {
461
+ this.runtimeStore.updateState('stage-release', (draft) => {
462
+ this.runtimeStore.releaseTaskProjection(draft, seq, { dropLease: true });
463
+ });
464
+ this.log.ok(`seq ${seq}: Worker slot ${slotName} released`);
465
+ }
466
+ catch (err) {
467
+ const msg = err instanceof Error ? err.message : String(err);
468
+ this.log.error(`seq ${seq}: Failed to release slot: ${msg}`);
469
+ errors.push(`release-slot: ${msg}`);
470
+ }
471
+ }
472
+ else {
473
+ if (state.activeCards[seq] || state.leases[seq]) {
474
+ try {
475
+ this.runtimeStore.updateState('stage-release', (draft) => {
476
+ this.runtimeStore.releaseTaskProjection(draft, seq, { dropLease: true });
477
+ });
478
+ }
479
+ catch { /* non-fatal */ }
480
+ }
481
+ }
482
+ // Step 4: Stop worker process
483
+ try {
484
+ const snapshots = this.workerManager.inspect({ taskId: seq });
485
+ for (const snap of snapshots) {
486
+ if (snap.pid && snap.pid > 0) {
487
+ try {
488
+ process.kill(snap.pid, 'SIGTERM');
489
+ }
490
+ catch { /* already dead */ }
491
+ }
492
+ }
493
+ }
494
+ catch (err) {
495
+ const msg = err instanceof Error ? err.message : String(err);
496
+ this.log.debug(`seq ${seq}: Worker cleanup: ${msg}`);
497
+ }
498
+ // Step 4b: Clear from IntegrationQueue
499
+ if (this.usesQueue) {
500
+ try {
501
+ const iq = new IntegrationQueue(this.ctx.paths.stateFile, this.ctx.config.MAX_CONCURRENT_WORKERS);
502
+ const active = iq.getActive(this.ctx.projectName, this.ctx.mergeBranch);
503
+ if (active && active.taskId === seq) {
504
+ iq.dequeueNext(this.ctx.projectName, this.ctx.mergeBranch);
505
+ this.log.ok(`seq ${seq}: Integration queue advanced`);
506
+ }
507
+ }
508
+ catch (err) {
509
+ const msg = err instanceof Error ? err.message : String(err);
510
+ this.log.warn(`seq ${seq}: Failed to advance integration queue: ${msg}`);
511
+ }
512
+ }
513
+ // Step 5: Mark worktree for cleanup
514
+ try {
515
+ const freshState = this.runtimeStore.readState();
516
+ const branchName = this.buildBranchName(card);
517
+ const worktreePath = runtime.lease?.worktree ||
518
+ runtime.evidence?.worktree ||
519
+ resolveWorktreePath(this.ctx.projectName, seq, this.ctx.config.WORKTREE_DIR);
520
+ const cleanup = freshState.worktreeCleanup ?? [];
521
+ const alreadyMarked = cleanup.some((e) => e.branch === branchName);
522
+ if (!alreadyMarked) {
523
+ cleanup.push({ branch: branchName, worktreePath, markedAt: new Date().toISOString() });
524
+ this.runtimeStore.updateState('stage-worktree-mark', (draft) => {
525
+ draft.worktreeCleanup = cleanup;
526
+ });
527
+ }
528
+ }
529
+ catch (err) {
530
+ const msg = err instanceof Error ? err.message : String(err);
531
+ this.log.error(`seq ${seq}: Failed to mark worktree for cleanup: ${msg}`);
532
+ errors.push(`worktree-mark: ${msg}`);
533
+ }
534
+ // Notify
535
+ if (errors.length === 0) {
536
+ await this.notifySafe(`✅ [${this.ctx.projectName}] seq:${seq} completed and released successfully`);
537
+ }
538
+ else {
539
+ await this.notifySafe(`⚠️ [${this.ctx.projectName}] seq:${seq} completed but release had errors: ${errors.join('; ')}`);
540
+ }
541
+ const actionResult = errors.length === 0 ? 'ok' : 'fail';
542
+ actions.push({
543
+ action: 'stage-complete',
544
+ entity: `seq:${seq}`,
545
+ result: actionResult,
546
+ message: errors.length === 0
547
+ ? `Completed → ${this.pipelineAdapter.states.done}, resources released`
548
+ : `Completed → ${this.pipelineAdapter.states.done} with errors: ${errors.join('; ')}`,
549
+ });
550
+ this.logEvent('stage-complete', seq, actionResult, errors.length > 0 ? { errors } : undefined);
551
+ }
552
+ // ─── First stage: active card check (detect completion) ────────
553
+ async checkActiveCard(card, _opts) {
554
+ const seq = card.seq;
555
+ const state = this.runtimeStore.readState();
556
+ const lease = state.leases[seq] || null;
557
+ const slotName = this.findRuntimeSlotName(state, seq, lease);
558
+ if (!slotName)
559
+ return null;
560
+ const snapshots = this.workerManager.inspect({ project: this.ctx.projectName, taskId: seq });
561
+ const snapshot = snapshots[0];
562
+ if (snapshot && (snapshot.state === 'running' || snapshot.state === 'starting')) {
563
+ try {
564
+ this.runtimeStore.updateState('stage-heartbeat', (freshState) => {
565
+ if (freshState.workers[slotName]) {
566
+ freshState.workers[slotName].lastHeartbeat = new Date().toISOString();
567
+ }
568
+ });
569
+ }
570
+ catch { /* non-fatal */ }
571
+ return null;
572
+ }
573
+ if (snapshot && (snapshot.state === 'waiting_input' || snapshot.state === 'needs_confirmation')) {
574
+ return null;
575
+ }
576
+ if (snapshot && snapshot.state === 'completed') {
577
+ this.log.ok(`seq ${seq}: Completed (handled by WM exit callback)`);
578
+ return { action: 'complete', entity: `seq:${seq}`, result: 'ok', message: 'Completed via WM exit callback' };
579
+ }
580
+ if (snapshot && snapshot.state === 'failed') {
581
+ this.log.info(`seq ${seq}: Failed (handled by WM exit callback)`);
582
+ return { action: 'complete', entity: `seq:${seq}`, result: 'fail', message: 'Failed via WM exit callback' };
583
+ }
584
+ const freshState = this.runtimeStore.readState();
585
+ if (!freshState.workers[slotName] || freshState.workers[slotName].status === 'idle') {
586
+ return { action: 'complete', entity: `seq:${seq}`, result: 'ok', message: 'Completed (WM processed)' };
587
+ }
588
+ return null;
589
+ }
590
+ // ─── First stage: prepare (Backlog → Ready) ────────────────────
591
+ async prepareCard(card, opts) {
592
+ const seq = card.seq;
593
+ const branchName = this.buildBranchName(card);
594
+ const worktreePath = resolveWorktreePath(this.ctx.projectName, seq, this.ctx.config.WORKTREE_DIR);
595
+ if (opts.dryRun) {
596
+ return { action: 'prepare', entity: `seq:${seq}`, result: 'ok', message: 'dry-run' };
597
+ }
598
+ // Step 1: Create branch
599
+ try {
600
+ await this.repoBackend.ensureBranch(this.ctx.paths.repoDir, branchName, this.ctx.mergeBranch);
601
+ this.log.ok(`Step 1: Branch ${branchName} created for seq ${seq}`);
602
+ }
603
+ catch (err) {
604
+ const msg = err instanceof Error ? err.message : String(err);
605
+ this.log.error(`Step 1 failed (branch) for seq ${seq}: ${msg}`);
606
+ this.logEvent('prepare-branch', seq, 'fail', { error: msg });
607
+ return { action: 'prepare', entity: `seq:${seq}`, result: 'fail', message: `Branch creation failed: ${msg}` };
608
+ }
609
+ // Step 2: Create worktree
610
+ try {
611
+ await this.repoBackend.ensureWorktree(this.ctx.paths.repoDir, branchName, worktreePath);
612
+ this.log.ok(`Step 2: Worktree created for seq ${seq} at ${worktreePath}`);
613
+ }
614
+ catch (err) {
615
+ const msg = err instanceof Error ? err.message : String(err);
616
+ this.log.error(`Step 2 failed (worktree) for seq ${seq}: ${msg}`);
617
+ this.logEvent('prepare-worktree', seq, 'fail', { error: msg });
618
+ return { action: 'prepare', entity: `seq:${seq}`, result: 'fail', message: `Worktree creation failed: ${msg}` };
619
+ }
620
+ // Step 3: Move card to Ready
621
+ try {
622
+ await this.taskBackend.move(seq, this.pipelineAdapter.states.ready);
623
+ this.log.ok(`Step 3: Moved seq ${seq} ${this.pipelineAdapter.states.backlog} → ${this.pipelineAdapter.states.ready}`);
624
+ this.logEvent('prepare', seq, 'ok');
625
+ await this.notifySafe(`ℹ️ [${this.ctx.projectName}] seq:${seq} environment ready (${this.pipelineAdapter.states.backlog} → ${this.pipelineAdapter.states.ready})`);
626
+ return { action: 'prepare', entity: `seq:${seq}`, result: 'ok', message: `${this.pipelineAdapter.states.backlog} → ${this.pipelineAdapter.states.ready}` };
627
+ }
628
+ catch (err) {
629
+ const msg = err instanceof Error ? err.message : String(err);
630
+ this.log.error(`Step 3 failed (move) for seq ${seq}: ${msg}`);
631
+ this.logEvent('prepare-move', seq, 'fail', { error: msg });
632
+ return { action: 'prepare', entity: `seq:${seq}`, result: 'fail', message: `Move to ${this.pipelineAdapter.states.ready} failed: ${msg}` };
633
+ }
634
+ }
635
+ // ─── First stage: launch worker (Ready → Active) ───────────────
636
+ async launchWorker(card, opts, failedSlots = new Set()) {
637
+ const seq = card.seq;
638
+ const branchName = this.buildBranchName(card);
639
+ const worktreePath = resolveWorktreePath(this.ctx.projectName, seq, this.ctx.config.WORKTREE_DIR);
640
+ const workflowTransport = resolveWorkflowTransport(this.ctx.config);
641
+ if (opts.dryRun) {
642
+ return { action: 'launch', entity: `seq:${seq}`, result: 'ok', message: 'dry-run' };
643
+ }
644
+ // PM claim
645
+ try {
646
+ await this.taskBackend.claim(seq, `pending-wm`);
647
+ }
648
+ catch (err) {
649
+ const msg = err instanceof Error ? err.message : String(err);
650
+ this.log.warn(`PM claim for seq ${seq} failed (non-fatal): ${msg}`);
651
+ }
652
+ // Build prompt
653
+ let prompt;
654
+ try {
655
+ prompt = this.buildStagePrompt(card, worktreePath, branchName);
656
+ }
657
+ catch (err) {
658
+ const msg = err instanceof Error ? err.message : String(err);
659
+ this.log.error(`Prompt build failed for seq ${seq}: ${msg}`);
660
+ this.logEvent('launch-context', seq, 'fail', { error: msg });
661
+ return { action: 'launch', entity: `seq:${seq}`, result: 'fail', message: `Context build failed: ${msg}` };
662
+ }
663
+ // Launch worker
664
+ const logsDir = this.ctx.config.raw.LOGS_DIR || `/tmp/sps-${this.ctx.projectName}`;
665
+ const runRequest = {
666
+ taskId: String(card.seq),
667
+ cardId: String(card.seq),
668
+ project: this.ctx.projectName,
669
+ phase: 'development',
670
+ prompt,
671
+ cwd: worktreePath,
672
+ branch: branchName,
673
+ targetBranch: this.ctx.mergeBranch,
674
+ tool: (this.stage.agent || this.ctx.config.WORKER_TOOL),
675
+ transport: 'acp-sdk',
676
+ outputFile: resolve(logsDir, `${this.ctx.projectName}-worker-${card.seq}-${Date.now()}.jsonl`),
677
+ timeoutSec: this.ctx.config.WORKER_LAUNCH_TIMEOUT_S,
678
+ maxRetries: this.ctx.config.WORKER_RESTART_LIMIT,
679
+ completionStrategy: this.stage.completion,
680
+ };
681
+ let response;
682
+ try {
683
+ response = await this.workerManager.run(runRequest);
684
+ }
685
+ catch (err) {
686
+ const msg = err instanceof Error ? err.message : String(err);
687
+ this.log.error(`WM.run failed for seq ${seq}: ${msg}`);
688
+ failedSlots.add(`wm-error-${seq}`);
689
+ this.logEvent('launch-worker', seq, 'fail', { error: msg });
690
+ return { action: 'launch', entity: `seq:${seq}`, result: 'fail', message: `Worker launch failed: ${msg}` };
691
+ }
692
+ if (!response.accepted) {
693
+ this.log.warn(`WM rejected seq ${seq}: ${response.rejectReason}`);
694
+ return {
695
+ action: 'launch',
696
+ entity: `seq:${seq}`,
697
+ result: response.rejectReason === 'resource_exhausted' ? 'skip' : 'fail',
698
+ message: `WM rejected: ${response.rejectReason}`,
699
+ };
700
+ }
701
+ const slotName = response.slot;
702
+ this.log.ok(`WM launched worker for seq ${seq} (slot=${slotName}, pid=${response.pid ?? 'n/a'})`);
703
+ await this.notifySafe(`▶️ [${this.ctx.projectName}] seq:${seq} worker started (${slotName})`);
704
+ // Move card to active state
705
+ try {
706
+ await this.taskBackend.move(seq, this.stage.activeState);
707
+ this.runtimeStore.updateState('stage-launch', (freshState) => {
708
+ if (freshState.activeCards[seq]) {
709
+ freshState.activeCards[seq].state = this.stage.activeState;
710
+ if (freshState.leases[seq]) {
711
+ freshState.leases[seq].pmStateObserved = this.stage.activeState;
712
+ if (freshState.leases[seq].phase === 'preparing' || freshState.leases[seq].phase === 'queued') {
713
+ freshState.leases[seq].phase = 'coding';
714
+ }
715
+ freshState.leases[seq].lastTransitionAt = new Date().toISOString();
716
+ }
717
+ }
718
+ });
719
+ this.log.ok(`Moved seq ${seq} ${this.pipelineAdapter.states.ready} → ${this.stage.activeState}`);
720
+ this.logEvent('launch', seq, 'ok', { worker: slotName });
721
+ return { action: 'launch', entity: `seq:${seq}`, result: 'ok', message: `${this.pipelineAdapter.states.ready} → ${this.stage.activeState} (${slotName})` };
722
+ }
723
+ catch (err) {
724
+ const msg = err instanceof Error ? err.message : String(err);
725
+ this.log.error(`Move failed for seq ${seq}: ${msg}`);
726
+ try {
727
+ await this.workerManager.cancel({ taskId: String(card.seq), project: this.ctx.projectName, reason: 'anomaly' });
728
+ }
729
+ catch { /* best effort */ }
730
+ this.releaseSlot(slotName, seq);
731
+ this.logEvent('launch-move', seq, 'fail', { error: msg });
732
+ return { action: 'launch', entity: `seq:${seq}`, result: 'fail', message: `Move to ${this.stage.activeState} failed: ${msg}` };
733
+ }
734
+ }
735
+ // ─── Runtime helpers ───────────────────────────────────────────
736
+ async listRuntimeAwareActiveCards() {
737
+ const cards = await this.taskBackend.listByState(this.stage.activeState);
738
+ const bySeq = new Map(cards.map(card => [card.seq, card]));
739
+ const state = this.runtimeStore.readState();
740
+ for (const [seq, lease] of Object.entries(state.leases)) {
741
+ const slot = lease.slot ? state.workers[lease.slot] || null : null;
742
+ if (this.derivePmStateFromLease(lease, slot) !== this.stage.activeState || bySeq.has(seq))
743
+ continue;
744
+ const card = await this.taskBackend.getBySeq(seq);
745
+ if (card)
746
+ bySeq.set(seq, card);
747
+ }
748
+ return Array.from(bySeq.values()).sort((a, b) => parseInt(a.seq, 10) - parseInt(b.seq, 10));
749
+ }
750
+ async reconcilePmStatesWithRuntime() {
751
+ const state = this.runtimeStore.readState();
752
+ const actions = [];
753
+ for (const [seq, lease] of Object.entries(state.leases)) {
754
+ const slot = lease.slot ? state.workers[lease.slot] || null : null;
755
+ const targetState = this.derivePmStateFromLease(lease, slot);
756
+ if (!targetState)
757
+ continue;
758
+ const card = await this.taskBackend.getBySeq(seq);
759
+ if (!card || card.state === targetState)
760
+ continue;
761
+ try {
762
+ await this.taskBackend.move(seq, targetState);
763
+ this.log.info(`Reconciled seq ${seq} ${card.state} → ${targetState} to match runtime state`);
764
+ actions.push({
765
+ action: 'pm-reconcile',
766
+ entity: `seq:${seq}`,
767
+ result: 'ok',
768
+ message: `${card.state} → ${targetState}`,
769
+ });
770
+ }
771
+ catch (err) {
772
+ const msg = err instanceof Error ? err.message : String(err);
773
+ this.log.warn(`Failed to reconcile PM state for seq ${seq}: ${msg}`);
774
+ actions.push({
775
+ action: 'pm-reconcile',
776
+ entity: `seq:${seq}`,
777
+ result: 'skip',
778
+ message: msg,
779
+ });
780
+ }
781
+ }
782
+ return actions;
783
+ }
784
+ derivePmStateFromLease(lease, slot) {
785
+ const s = this.pipelineAdapter.states;
786
+ if (lease.phase === 'queued' || lease.phase === 'preparing') {
787
+ return s.ready;
788
+ }
789
+ if (lease.phase === 'coding') {
790
+ return this.stage.activeState;
791
+ }
792
+ if (lease.phase === 'waiting_confirmation') {
793
+ // If in a later stage (merging context), keep current observed state
794
+ if (lease.pmStateObserved && lease.pmStateObserved !== this.stage.activeState
795
+ && slot?.status !== 'merging' && slot?.status !== 'resolving') {
796
+ return this.stage.activeState;
797
+ }
798
+ return lease.pmStateObserved || this.stage.activeState;
799
+ }
800
+ if (['merging', 'resolving_conflict', 'closing'].includes(lease.phase)) {
801
+ // Find the stage that handles merging (has fast-forward-merge completion)
802
+ const mergeStage = this.pipelineAdapter.stages.find(st => st.completion === 'fast-forward-merge');
803
+ return mergeStage?.activeState || this.stage.activeState;
804
+ }
805
+ return null;
806
+ }
807
+ // ─── Prompt building ───────────────────────────────────────────
808
+ buildStagePrompt(card, worktreePath, branchName) {
809
+ const skillContent = this.loadSkillProfiles(card);
810
+ let projectRules = '';
811
+ const claudeMdPath = resolve(worktreePath, 'CLAUDE.md');
812
+ const agentsMdPath = resolve(worktreePath, 'AGENTS.md');
813
+ if (existsSync(claudeMdPath)) {
814
+ projectRules = readFileSync(claudeMdPath, 'utf-8').trim();
815
+ }
816
+ if (existsSync(agentsMdPath)) {
817
+ const agentsRules = readFileSync(agentsMdPath, 'utf-8').trim();
818
+ projectRules = projectRules ? `${projectRules}\n\n${agentsRules}` : agentsRules;
819
+ }
820
+ // Memory system: inject user + project memories + write instructions
821
+ const memoryContext = buildFullMemoryContext({ project: this.ctx.projectName, cardSeq: card.seq });
822
+ const memoryInstructions = buildMemoryWriteInstructions(this.ctx.projectName);
823
+ const knowledge = [memoryContext, memoryInstructions].filter(Boolean).join('\n\n---\n\n');
824
+ // Determine phase for prompt generation (legacy compatibility)
825
+ const phase = this.stage.completion === 'fast-forward-merge' ? 'integration' : 'development';
826
+ const prompt = buildPhasePrompt({
827
+ taskSeq: card.seq,
828
+ taskTitle: card.name,
829
+ taskDescription: card.desc || '(no description)',
830
+ cardId: card.id,
831
+ worktreePath,
832
+ branchName,
833
+ targetBranch: this.ctx.mergeBranch,
834
+ mergeMode: this.ctx.mrMode,
835
+ gitlabProjectId: resolveGitlabProjectId(this.ctx.config),
836
+ skillContent,
837
+ projectRules: projectRules || undefined,
838
+ knowledge,
839
+ phase,
840
+ });
841
+ // Archive to .sps/ for debugging
842
+ try {
843
+ const spsDir = resolve(worktreePath, '.sps');
844
+ if (!existsSync(spsDir))
845
+ mkdirSync(spsDir, { recursive: true });
846
+ writeFileSync(resolve(spsDir, `${this.stage.name}_prompt.txt`), prompt);
847
+ }
848
+ catch { /* archive failure should never block worker launch */ }
849
+ return prompt;
850
+ }
851
+ loadSkillProfiles(card) {
852
+ let skills = card.labels
853
+ .filter(l => l.startsWith('skill:'))
854
+ .map(l => l.slice('skill:'.length));
855
+ if (skills.length === 0 && this.stage.profile) {
856
+ skills = this.stage.profile.split(',').map(s => s.trim()).filter(Boolean);
857
+ }
858
+ if (skills.length === 0) {
859
+ const defaultSkills = this.ctx.config.raw.DEFAULT_WORKER_SKILLS;
860
+ if (defaultSkills) {
861
+ skills = defaultSkills.split(',').map(s => s.trim()).filter(Boolean);
862
+ }
863
+ }
864
+ if (skills.length === 0)
865
+ return '';
866
+ this.log.ok(`Skill labels: ${skills.join(', ')}`);
867
+ return `# Required Skills\n\nThis task requires the following skills: ${skills.join(', ')}.\nLoad the dev-worker skill and read the corresponding references.`;
868
+ }
869
+ // loadProjectKnowledge removed — replaced by memory system (buildMemoryContext)
870
+ // ─── Worktree cleanup (last stage only) ────────────────────────
871
+ async cleanupWorktrees(actions) {
872
+ const state = this.runtimeStore.readState();
873
+ const queue = state.worktreeCleanup ?? [];
874
+ if (queue.length === 0)
875
+ return;
876
+ const now = Date.now();
877
+ const ready = queue.filter(e => now - new Date(e.markedAt).getTime() >= 30_000);
878
+ const deferred = queue.filter(e => now - new Date(e.markedAt).getTime() < 30_000);
879
+ if (ready.length === 0) {
880
+ if (deferred.length > 0) {
881
+ this.log.debug(`${deferred.length} worktree(s) deferred — waiting for worker shutdown`);
882
+ }
883
+ return;
884
+ }
885
+ this.log.info(`Cleaning up ${ready.length} worktree(s)`);
886
+ const remaining = [...deferred];
887
+ for (const entry of ready) {
888
+ try {
889
+ await this.repoBackend.removeWorktree(this.ctx.paths.repoDir, entry.worktreePath, entry.branch);
890
+ this.log.ok(`Cleaned up worktree: ${entry.branch}`);
891
+ actions.push({
892
+ action: 'worktree-cleanup',
893
+ entity: entry.branch,
894
+ result: 'ok',
895
+ message: `Removed worktree ${entry.worktreePath}`,
896
+ });
897
+ this.logEvent('worktree-cleanup', entry.branch, 'ok');
898
+ }
899
+ catch (err) {
900
+ const msg = err instanceof Error ? err.message : String(err);
901
+ this.log.warn(`Failed to clean up worktree ${entry.branch}: ${msg}`);
902
+ remaining.push(entry);
903
+ actions.push({
904
+ action: 'worktree-cleanup',
905
+ entity: entry.branch,
906
+ result: 'fail',
907
+ message: `Cleanup failed: ${msg}`,
908
+ });
909
+ }
910
+ }
911
+ this.runtimeStore.updateState('stage-worktree-cleanup', (freshState) => {
912
+ freshState.worktreeCleanup = remaining;
913
+ });
914
+ }
915
+ // ─── Merge detection ───────────────────────────────────────────
916
+ isMergedToBase(worktree, branchName) {
917
+ try {
918
+ execFileSync('git', ['-C', worktree, 'fetch', 'origin', this.ctx.mergeBranch], { stdio: 'ignore' });
919
+ }
920
+ catch { /* best effort */ }
921
+ try {
922
+ execFileSync('git', ['-C', worktree, 'merge-base', '--is-ancestor', branchName, `origin/${this.ctx.mergeBranch}`], { stdio: 'ignore' });
923
+ }
924
+ catch {
925
+ return false;
926
+ }
927
+ const pushed = branchPushed(worktree, branchName);
928
+ const localAhead = branchCommitsAhead(worktree, branchName, this.ctx.mergeBranch);
929
+ if (pushed || localAhead > 0)
930
+ return true;
931
+ this.log.debug(`branch ${branchName}: ancestor of ${this.ctx.mergeBranch} but no artifacts — not a real merge`);
932
+ return false;
933
+ }
934
+ // ─── Common helpers ────────────────────────────────────────────
935
+ shouldSkip(card) {
936
+ return SKIP_LABELS.some((label) => card.labels.includes(label));
937
+ }
938
+ async cleanAuxiliaryLabels(card) {
939
+ for (const label of CLEANUP_LABELS) {
940
+ if (card.labels.includes(label)) {
941
+ try {
942
+ await this.taskBackend.removeLabel(card.seq, label);
943
+ card.labels = card.labels.filter(l => l !== label);
944
+ this.log.ok(`Removed stale label "${label}" from seq ${card.seq}`);
945
+ }
946
+ catch {
947
+ this.log.warn(`Failed to remove label "${label}" from seq ${card.seq}`);
948
+ }
949
+ }
950
+ }
951
+ }
952
+ buildBranchName(card) {
953
+ const slug = card.name
954
+ .toLowerCase()
955
+ .replace(/[^a-z0-9]+/g, '-')
956
+ .replace(/^-|-$/g, '')
957
+ .slice(0, 40);
958
+ return `feature/${card.seq}-${slug}`;
959
+ }
960
+ findRuntimeSlotName(state, seq, lease) {
961
+ if (lease?.slot && state.workers[lease.slot])
962
+ return lease.slot;
963
+ const slotEntry = Object.entries(state.workers).find(([, worker]) => worker.seq === parseInt(seq, 10) && worker.status !== 'idle');
964
+ return slotEntry?.[0] || null;
965
+ }
966
+ releaseSlot(slotName, seq) {
967
+ try {
968
+ this.runtimeStore.updateState('stage-release-slot', (state) => {
969
+ this.runtimeStore.releaseTaskProjection(state, seq, { dropLease: true });
970
+ });
971
+ this.taskBackend.releaseClaim(seq).catch(() => { });
972
+ }
973
+ catch {
974
+ this.log.warn(`Failed to release slot ${slotName} for seq ${seq}`);
975
+ }
976
+ }
977
+ async releaseSlotForStage(seq, slotName) {
978
+ try {
979
+ await this.workerManager.cancel({ taskId: seq, project: this.ctx.projectName, reason: 'anomaly' });
980
+ }
981
+ catch (err) {
982
+ const msg = err instanceof Error ? err.message : String(err);
983
+ this.log.warn(`seq ${seq}: WM cancel failed: ${msg}`);
984
+ }
985
+ this.runtimeStore.updateState(`stage-${this.stage.name}-release`, (draft) => {
986
+ this.runtimeStore.releaseTaskProjection(draft, seq, {
987
+ dropLease: false,
988
+ phase: this.stage.completion === 'fast-forward-merge' ? 'merging' : 'coding',
989
+ keepWorktree: true,
990
+ pmStateObserved: this.stage.activeState,
991
+ });
992
+ });
993
+ try {
994
+ await this.taskBackend.releaseClaim(seq);
995
+ }
996
+ catch (err) {
997
+ const msg = err instanceof Error ? err.message : String(err);
998
+ this.log.warn(`seq ${seq}: Failed to release claim for ${slotName}: ${msg}`);
999
+ }
1000
+ }
1001
+ async markNeedsFix(seq, reason) {
1002
+ const label = this.stage.onFailLabel || 'NEEDS-FIX';
1003
+ const comment = this.stage.onFailComment || reason;
1004
+ await this.addLabelSafe(seq, label);
1005
+ await this.commentSafe(seq, `${label}: ${comment}`);
1006
+ await this.notifySafe(`⚠️ [${this.ctx.projectName}] seq:${seq} marked ${label}: ${reason}`);
1007
+ }
1008
+ async addLabelSafe(seq, label) {
1009
+ try {
1010
+ await this.taskBackend.addLabel(seq, label);
1011
+ }
1012
+ catch (err) {
1013
+ this.log.error(`Failed to add label ${label} to seq ${seq}: ${err instanceof Error ? err.message : err}`);
1014
+ }
1015
+ }
1016
+ async commentSafe(seq, text) {
1017
+ try {
1018
+ await this.taskBackend.comment(seq, text);
1019
+ }
1020
+ catch (err) {
1021
+ this.log.error(`Failed to comment on seq ${seq}: ${err instanceof Error ? err.message : err}`);
1022
+ }
1023
+ }
1024
+ async notifySafe(message) {
1025
+ if (!this.notifier)
1026
+ return;
1027
+ try {
1028
+ await this.notifier.send(message);
1029
+ }
1030
+ catch { /* non-fatal */ }
1031
+ }
1032
+ logEvent(action, seq, result, meta) {
1033
+ this.log.event({
1034
+ component: `stage-${this.stage.name}`,
1035
+ action,
1036
+ entity: `seq:${seq}`,
1037
+ result,
1038
+ meta,
1039
+ });
1040
+ }
1041
+ // ─── Public: single-card launch (for sps worker launch) ────────
1042
+ /**
1043
+ * Launch a single card (for `sps worker launch <project> <seq>`).
1044
+ * Only available on the first stage.
1045
+ */
1046
+ async launchSingle(seq, opts = {}) {
1047
+ const result = {
1048
+ project: this.ctx.projectName,
1049
+ component: 'worker-launch',
1050
+ status: 'ok',
1051
+ exitCode: 0,
1052
+ actions: [],
1053
+ recommendedActions: [],
1054
+ details: {},
1055
+ };
1056
+ if (!this.isFirstStage) {
1057
+ result.status = 'fail';
1058
+ result.exitCode = 2;
1059
+ result.details = { error: 'launchSingle only available on first stage' };
1060
+ return result;
1061
+ }
1062
+ const card = await this.taskBackend.getBySeq(seq);
1063
+ if (!card) {
1064
+ result.status = 'fail';
1065
+ result.exitCode = 1;
1066
+ result.details = { error: `Card seq:${seq} not found` };
1067
+ return result;
1068
+ }
1069
+ // If card is in Backlog, do prepare first
1070
+ if (card.state === this.pipelineAdapter.states.backlog) {
1071
+ const prepareAction = await this.prepareCard(card, opts);
1072
+ result.actions.push(prepareAction);
1073
+ if (prepareAction.result === 'fail') {
1074
+ result.status = 'fail';
1075
+ result.exitCode = 1;
1076
+ return result;
1077
+ }
1078
+ const updated = await this.taskBackend.getBySeq(seq);
1079
+ if (!updated || updated.state !== this.pipelineAdapter.states.ready) {
1080
+ result.status = 'fail';
1081
+ result.exitCode = 1;
1082
+ result.details = { error: `Card not in ${this.pipelineAdapter.states.ready} after prepare` };
1083
+ return result;
1084
+ }
1085
+ }
1086
+ if (card.state !== this.pipelineAdapter.states.ready && card.state !== this.pipelineAdapter.states.backlog) {
1087
+ result.status = 'fail';
1088
+ result.exitCode = 2;
1089
+ result.details = { error: `Card seq:${seq} is in ${card.state}, expected ${this.pipelineAdapter.states.backlog} or ${this.pipelineAdapter.states.ready}` };
1090
+ return result;
1091
+ }
1092
+ const launchAction = await this.launchWorker(card, opts);
1093
+ result.actions.push(launchAction);
1094
+ if (launchAction.result === 'fail') {
1095
+ result.status = 'fail';
1096
+ result.exitCode = 1;
1097
+ }
1098
+ return result;
1099
+ }
1100
+ }
1101
+ //# sourceMappingURL=StageEngine.js.map