kantban-cli 0.1.6 → 0.1.8

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 (118) hide show
  1. package/dist/client.d.ts +3 -0
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +54 -0
  4. package/dist/client.js.map +1 -1
  5. package/dist/commands/cron.js +2 -2
  6. package/dist/commands/cron.js.map +1 -1
  7. package/dist/commands/pipeline-init.d.ts +2 -0
  8. package/dist/commands/pipeline-init.d.ts.map +1 -0
  9. package/dist/commands/pipeline-init.js +100 -0
  10. package/dist/commands/pipeline-init.js.map +1 -0
  11. package/dist/commands/pipeline.d.ts.map +1 -1
  12. package/dist/commands/pipeline.js +637 -44
  13. package/dist/commands/pipeline.js.map +1 -1
  14. package/dist/lib/advisor.d.ts +108 -0
  15. package/dist/lib/advisor.d.ts.map +1 -0
  16. package/dist/lib/advisor.js +139 -0
  17. package/dist/lib/advisor.js.map +1 -0
  18. package/dist/lib/checkpoint.d.ts +15 -0
  19. package/dist/lib/checkpoint.d.ts.map +1 -0
  20. package/dist/lib/checkpoint.js +49 -0
  21. package/dist/lib/checkpoint.js.map +1 -0
  22. package/dist/lib/constraint-evaluator.d.ts +40 -0
  23. package/dist/lib/constraint-evaluator.d.ts.map +1 -0
  24. package/dist/lib/constraint-evaluator.js +189 -0
  25. package/dist/lib/constraint-evaluator.js.map +1 -0
  26. package/dist/lib/cost-tracker.d.ts +46 -0
  27. package/dist/lib/cost-tracker.d.ts.map +1 -0
  28. package/dist/lib/cost-tracker.js +120 -0
  29. package/dist/lib/cost-tracker.js.map +1 -0
  30. package/dist/lib/evaluator.d.ts +17 -0
  31. package/dist/lib/evaluator.d.ts.map +1 -0
  32. package/dist/lib/evaluator.js +71 -0
  33. package/dist/lib/evaluator.js.map +1 -0
  34. package/dist/lib/event-emitter.d.ts +28 -0
  35. package/dist/lib/event-emitter.d.ts.map +1 -0
  36. package/dist/lib/event-emitter.js +100 -0
  37. package/dist/lib/event-emitter.js.map +1 -0
  38. package/dist/lib/gate-config.d.ts +7 -0
  39. package/dist/lib/gate-config.d.ts.map +1 -0
  40. package/dist/lib/gate-config.js +68 -0
  41. package/dist/lib/gate-config.js.map +1 -0
  42. package/dist/lib/gate-proxy-server.d.ts +16 -0
  43. package/dist/lib/gate-proxy-server.d.ts.map +1 -0
  44. package/dist/lib/gate-proxy-server.js +385 -0
  45. package/dist/lib/gate-proxy-server.js.map +1 -0
  46. package/dist/lib/gate-proxy.d.ts +46 -0
  47. package/dist/lib/gate-proxy.d.ts.map +1 -0
  48. package/dist/lib/gate-proxy.js +104 -0
  49. package/dist/lib/gate-proxy.js.map +1 -0
  50. package/dist/lib/gate-runner.d.ts +13 -0
  51. package/dist/lib/gate-runner.d.ts.map +1 -0
  52. package/dist/lib/gate-runner.js +104 -0
  53. package/dist/lib/gate-runner.js.map +1 -0
  54. package/dist/lib/gate-snapshot.d.ts +12 -0
  55. package/dist/lib/gate-snapshot.d.ts.map +1 -0
  56. package/dist/lib/gate-snapshot.js +49 -0
  57. package/dist/lib/gate-snapshot.js.map +1 -0
  58. package/dist/lib/light-call.d.ts +37 -0
  59. package/dist/lib/light-call.d.ts.map +1 -0
  60. package/dist/lib/light-call.js +62 -0
  61. package/dist/lib/light-call.js.map +1 -0
  62. package/dist/lib/logger.d.ts +2 -0
  63. package/dist/lib/logger.d.ts.map +1 -1
  64. package/dist/lib/logger.js +55 -9
  65. package/dist/lib/logger.js.map +1 -1
  66. package/dist/lib/mcp-config.d.ts +15 -0
  67. package/dist/lib/mcp-config.d.ts.map +1 -1
  68. package/dist/lib/mcp-config.js +70 -6
  69. package/dist/lib/mcp-config.js.map +1 -1
  70. package/dist/lib/orchestrator.d.ts +220 -6
  71. package/dist/lib/orchestrator.d.ts.map +1 -1
  72. package/dist/lib/orchestrator.js +1265 -58
  73. package/dist/lib/orchestrator.js.map +1 -1
  74. package/dist/lib/parse-utils.d.ts +6 -0
  75. package/dist/lib/parse-utils.d.ts.map +1 -0
  76. package/dist/lib/parse-utils.js +64 -0
  77. package/dist/lib/parse-utils.js.map +1 -0
  78. package/dist/lib/prompt-composer.d.ts +30 -1
  79. package/dist/lib/prompt-composer.d.ts.map +1 -1
  80. package/dist/lib/prompt-composer.js +162 -27
  81. package/dist/lib/prompt-composer.js.map +1 -1
  82. package/dist/lib/ralph-loop.d.ts +78 -4
  83. package/dist/lib/ralph-loop.d.ts.map +1 -1
  84. package/dist/lib/ralph-loop.js +249 -40
  85. package/dist/lib/ralph-loop.js.map +1 -1
  86. package/dist/lib/reaper.d.ts +14 -0
  87. package/dist/lib/reaper.d.ts.map +1 -0
  88. package/dist/lib/reaper.js +114 -0
  89. package/dist/lib/reaper.js.map +1 -0
  90. package/dist/lib/replanner.d.ts +49 -0
  91. package/dist/lib/replanner.d.ts.map +1 -0
  92. package/dist/lib/replanner.js +61 -0
  93. package/dist/lib/replanner.js.map +1 -0
  94. package/dist/lib/run-memory.d.ts +37 -0
  95. package/dist/lib/run-memory.d.ts.map +1 -0
  96. package/dist/lib/run-memory.js +115 -0
  97. package/dist/lib/run-memory.js.map +1 -0
  98. package/dist/lib/stream-parser.d.ts +20 -0
  99. package/dist/lib/stream-parser.d.ts.map +1 -0
  100. package/dist/lib/stream-parser.js +65 -0
  101. package/dist/lib/stream-parser.js.map +1 -0
  102. package/dist/lib/stuck-detector.d.ts +47 -0
  103. package/dist/lib/stuck-detector.d.ts.map +1 -0
  104. package/dist/lib/stuck-detector.js +105 -0
  105. package/dist/lib/stuck-detector.js.map +1 -0
  106. package/dist/lib/tool-profiles.d.ts +19 -0
  107. package/dist/lib/tool-profiles.d.ts.map +1 -0
  108. package/dist/lib/tool-profiles.js +22 -0
  109. package/dist/lib/tool-profiles.js.map +1 -0
  110. package/dist/lib/worktree.d.ts +12 -0
  111. package/dist/lib/worktree.d.ts.map +1 -0
  112. package/dist/lib/worktree.js +29 -0
  113. package/dist/lib/worktree.js.map +1 -0
  114. package/dist/lib/ws-client.d.ts +1 -1
  115. package/dist/lib/ws-client.d.ts.map +1 -1
  116. package/dist/lib/ws-client.js +5 -2
  117. package/dist/lib/ws-client.js.map +1 -1
  118. package/package.json +3 -1
@@ -1,13 +1,26 @@
1
- import { execFile, execSync } from 'node:child_process';
2
- import { mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync } from 'node:fs';
1
+ import { spawn, execSync } from 'node:child_process';
2
+ import { mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync, appendFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { PipelineOrchestrator } from '../lib/orchestrator.js';
6
6
  import { RalphLoop } from '../lib/ralph-loop.js';
7
+ import { RunMemory } from '../lib/run-memory.js';
7
8
  import { EventQueue } from '../lib/event-queue.js';
8
9
  import { PipelineWsClient } from '../lib/ws-client.js';
9
- import { generateMcpConfig, cleanupMcpConfig } from '../lib/mcp-config.js';
10
+ import { generateMcpConfig, generateGateProxyMcpConfig, cleanupMcpConfig, cleanupGateProxyConfigs } from '../lib/mcp-config.js';
10
11
  import { PipelineLogger } from '../lib/logger.js';
12
+ import { composeLightPrompt, parseLightResponse } from '../lib/light-call.js';
13
+ import { composeAdvisorPrompt, parseAdvisorResponse } from '../lib/advisor.js';
14
+ import { composeStuckDetectionPrompt, parseStuckDetectionResponse } from '../lib/stuck-detector.js';
15
+ import { cleanupWorktree } from '../lib/worktree.js';
16
+ import { spawnReaper, killReaper } from '../lib/reaper.js';
17
+ import { StreamJsonParser } from '../lib/stream-parser.js';
18
+ import { parseGateConfig, resolveGatesForColumn, parseTimeout } from '../lib/gate-config.js';
19
+ import { GateSnapshotStore } from '../lib/gate-snapshot.js';
20
+ import { runGates } from '../lib/gate-runner.js';
21
+ import { PipelineCostTracker } from '../lib/cost-tracker.js';
22
+ import { composeReplannerPrompt, parseReplannerResponse } from '../lib/replanner.js';
23
+ import { PipelineEventEmitter } from '../lib/event-emitter.js';
11
24
  function parseArgs(args) {
12
25
  const positional = [];
13
26
  let once = false;
@@ -75,6 +88,11 @@ function parseArgs(args) {
75
88
  }
76
89
  }
77
90
  const boardId = positional[0] ?? '';
91
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
92
+ if (boardId && !UUID_RE.test(boardId)) {
93
+ console.error(`Error: invalid board ID "${boardId}" — expected UUID`);
94
+ process.exit(1);
95
+ }
78
96
  if (!boardId) {
79
97
  console.error(`Usage: kantban pipeline <board-id> [flags]
80
98
 
@@ -103,8 +121,8 @@ function pidFilePath(boardId) {
103
121
  }
104
122
  function writePidFile(boardId) {
105
123
  const dir = pidDir(boardId);
106
- mkdirSync(dir, { recursive: true });
107
- writeFileSync(pidFilePath(boardId), String(process.pid));
124
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
125
+ writeFileSync(pidFilePath(boardId), String(process.pid), { mode: 0o600 });
108
126
  }
109
127
  function removePidFile(boardId) {
110
128
  try {
@@ -114,6 +132,51 @@ function removePidFile(boardId) {
114
132
  /* already removed */
115
133
  }
116
134
  }
135
+ // ---------------------------------------------------------------------------
136
+ // Child PID manifest helpers — tracks spawned child PIDs on disk for orphan
137
+ // cleanup by the watchdog reaper or next-startup cleanup.
138
+ // ---------------------------------------------------------------------------
139
+ function childManifestPath(boardId) {
140
+ return join(pidDir(boardId), 'children.pid');
141
+ }
142
+ function appendChildPid(boardId, pid) {
143
+ const dir = pidDir(boardId);
144
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
145
+ appendFileSync(childManifestPath(boardId), `${String(pid)}\n`, { mode: 0o600 });
146
+ }
147
+ function removeChildPid(boardId, pid) {
148
+ const manifestPath = childManifestPath(boardId);
149
+ try {
150
+ const contents = readFileSync(manifestPath, 'utf-8');
151
+ const pids = contents.split('\n').filter(l => l.trim() !== '' && l.trim() !== String(pid));
152
+ writeFileSync(manifestPath, pids.length > 0 ? pids.join('\n') + '\n' : '', { mode: 0o600 });
153
+ }
154
+ catch {
155
+ /* manifest doesn't exist or unreadable — nothing to remove */
156
+ }
157
+ }
158
+ function readChildManifest(boardId) {
159
+ try {
160
+ const contents = readFileSync(childManifestPath(boardId), 'utf-8');
161
+ return contents
162
+ .split('\n')
163
+ .map(l => l.trim())
164
+ .filter(l => l !== '')
165
+ .map(Number)
166
+ .filter(n => !isNaN(n) && n > 0);
167
+ }
168
+ catch {
169
+ return [];
170
+ }
171
+ }
172
+ function removeChildManifest(boardId) {
173
+ try {
174
+ unlinkSync(childManifestPath(boardId));
175
+ }
176
+ catch {
177
+ /* already removed */
178
+ }
179
+ }
117
180
  /**
118
181
  * Kill any orphaned pipeline processes from a previous run.
119
182
  * Reads the stale PID file, kills that process tree, then removes the file.
@@ -141,9 +204,46 @@ function cleanupOrphanedProcesses(boardId) {
141
204
  }
142
205
  removePidFile(boardId);
143
206
  }
144
- // 2. Kill orphaned claude -p processes with kantban-pipeline MCP configs
207
+ // 1.5. Kill children from the PID manifest (more targeted than grep)
208
+ const manifestPids = readChildManifest(boardId);
209
+ if (manifestPids.length > 0) {
210
+ for (const pid of manifestPids) {
211
+ try {
212
+ process.kill(pid, 0); // test if alive
213
+ process.kill(pid, 'SIGTERM');
214
+ }
215
+ catch {
216
+ // already dead
217
+ }
218
+ }
219
+ console.log(`Killed ${String(manifestPids.length)} orphaned child process(es) from manifest`);
220
+ removeChildManifest(boardId);
221
+ }
222
+ // 1.6. Kill stale reaper if still running
223
+ const staleReaperPath = join(pidDir(boardId), 'reaper.pid');
224
+ try {
225
+ if (existsSync(staleReaperPath)) {
226
+ const reaperPid = parseInt(readFileSync(staleReaperPath, 'utf-8').trim(), 10);
227
+ if (reaperPid && !isNaN(reaperPid)) {
228
+ try {
229
+ process.kill(reaperPid, 0);
230
+ process.kill(reaperPid, 'SIGTERM');
231
+ console.log(`Killed stale reaper (PID ${String(reaperPid)})`);
232
+ }
233
+ catch {
234
+ // already dead
235
+ }
236
+ }
237
+ unlinkSync(staleReaperPath);
238
+ }
239
+ }
240
+ catch {
241
+ // can't read reaper PID — not critical
242
+ }
243
+ // 2. Kill orphaned claude -p processes with THIS BOARD's MCP configs
145
244
  try {
146
- const out = execSync(`ps aux | grep 'claude.*-p.*kantban-pipeline' | grep -v grep | awk '{print $2}'`, { encoding: 'utf-8', timeout: 5000 }).trim();
245
+ const boardDir = pidDir(boardId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
246
+ const out = execSync(`ps aux | grep 'claude.*-p' | grep '${boardDir}' | grep -v grep | awk '{print $2}'`, { encoding: 'utf-8', timeout: 5000 }).trim();
147
247
  if (out) {
148
248
  const pids = out.split('\n').filter(Boolean);
149
249
  for (const pid of pids) {
@@ -169,37 +269,126 @@ function cleanupOrphanedProcesses(boardId) {
169
269
  // the group (e.g. on crash) takes all children down with it.
170
270
  // ---------------------------------------------------------------------------
171
271
  const activeChildProcesses = new Set();
272
+ let currentBoardId = '';
172
273
  const CLAUDE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour max per invocation
173
274
  async function invokeClaudeP(prompt, options) {
174
- const args = ['-p', prompt, '--mcp-config', options.mcpConfigPath, '--dangerously-skip-permissions'];
275
+ const args = ['-p', prompt, '--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose'];
276
+ // MCP config — omit for toolless invocations
277
+ if (options.includeMcpConfig !== false && options.mcpConfigPath) {
278
+ args.push('--mcp-config', options.mcpConfigPath);
279
+ }
175
280
  if (options.model)
176
281
  args.push('--model', options.model);
177
- if (options.maxBudgetUsd)
178
- args.push('--max-turns', String(Math.ceil(options.maxBudgetUsd * 10)));
282
+ if (options.maxTurns) {
283
+ args.push('--max-turns', String(options.maxTurns));
284
+ }
285
+ else if (options.maxBudgetUsd !== undefined && options.maxBudgetUsd !== null) {
286
+ args.push('--max-turns', String(Math.max(1, Math.ceil(options.maxBudgetUsd * 10))));
287
+ }
179
288
  if (options.worktree)
180
289
  args.push('--worktree', options.worktree);
290
+ // Tool scoping flags
291
+ if (options.tools !== undefined) {
292
+ args.push('--tools', options.tools);
293
+ }
294
+ if (options.allowedTools?.length) {
295
+ args.push('--allowedTools', ...options.allowedTools);
296
+ }
297
+ if (options.disallowedTools?.length) {
298
+ args.push('--disallowedTools', ...options.disallowedTools);
299
+ }
181
300
  return new Promise((resolve) => {
182
- const child = execFile('claude', args, {
183
- maxBuffer: 50 * 1024 * 1024, // 50MB
184
- timeout: CLAUDE_TIMEOUT_MS,
185
- }, (err, stdout) => {
186
- activeChildProcesses.delete(child);
187
- if (err) {
188
- const execErr = err;
189
- if (execErr.code === 'ENOENT') {
190
- resolve({ exitCode: 127, output: 'Error: "claude" command not found. Ensure Claude Code CLI is installed and on PATH.' });
191
- return;
192
- }
193
- const exitCode = typeof execErr.code === 'number'
194
- ? execErr.code
195
- : (execErr.killed ? 137 : 1);
196
- resolve({ exitCode, output: stdout || err.message || '' });
301
+ const child = spawn('claude', args, {
302
+ stdio: ['pipe', 'pipe', 'pipe'],
303
+ });
304
+ activeChildProcesses.add(child);
305
+ if (child.pid && currentBoardId)
306
+ appendChildPid(currentBoardId, child.pid);
307
+ const parser = new StreamJsonParser();
308
+ parser.on('error', (err) => {
309
+ process.stderr.write(`[stream-parser] ${err.message}\n`);
310
+ });
311
+ let lastOutput = '';
312
+ let tokensIn = 0;
313
+ let tokensOut = 0;
314
+ parser.on('event', (event) => {
315
+ // Capture final result metadata
316
+ if (event.type === 'result') {
317
+ const usage = event.usage;
318
+ tokensIn += usage?.input_tokens ?? 0;
319
+ tokensOut += usage?.output_tokens ?? 0;
320
+ if (typeof event.result === 'string')
321
+ lastOutput = event.result;
197
322
  }
198
- else {
199
- resolve({ exitCode: 0, output: stdout });
323
+ // Forward to stream callback if provided
324
+ options.onStreamEvent?.(event);
325
+ });
326
+ child.stdout?.on('data', (chunk) => {
327
+ parser.feed(chunk.toString());
328
+ });
329
+ // Capture stderr for error diagnostics
330
+ let stderr = '';
331
+ child.stderr?.on('data', (chunk) => {
332
+ stderr += chunk.toString();
333
+ });
334
+ // Close stdin immediately (we pass prompt via -p flag)
335
+ child.stdin?.end();
336
+ // Set up timeout
337
+ let killTimer;
338
+ const timeoutHandle = setTimeout(() => {
339
+ try {
340
+ child.kill('SIGTERM');
200
341
  }
342
+ catch { /* already dead */ }
343
+ // Escalate to SIGKILL if process doesn't exit within 5s
344
+ killTimer = setTimeout(() => {
345
+ if (!resolved) {
346
+ try {
347
+ child.kill('SIGKILL');
348
+ }
349
+ catch { /* already dead */ }
350
+ }
351
+ }, 5000);
352
+ }, CLAUDE_TIMEOUT_MS);
353
+ let resolved = false;
354
+ child.on('close', (code) => {
355
+ if (resolved)
356
+ return;
357
+ resolved = true;
358
+ clearTimeout(timeoutHandle);
359
+ if (killTimer)
360
+ clearTimeout(killTimer);
361
+ activeChildProcesses.delete(child);
362
+ if (child.pid && currentBoardId)
363
+ removeChildPid(currentBoardId, child.pid);
364
+ parser.flush();
365
+ resolve({
366
+ exitCode: code ?? 1,
367
+ output: lastOutput || stderr,
368
+ toolCallCount: parser.getToolCallCount(),
369
+ tokensIn,
370
+ tokensOut,
371
+ });
372
+ });
373
+ child.on('error', (err) => {
374
+ if (resolved)
375
+ return;
376
+ resolved = true;
377
+ clearTimeout(timeoutHandle);
378
+ if (killTimer)
379
+ clearTimeout(killTimer);
380
+ activeChildProcesses.delete(child);
381
+ if (child.pid && currentBoardId)
382
+ removeChildPid(currentBoardId, child.pid);
383
+ parser.flush();
384
+ resolve({
385
+ exitCode: 1,
386
+ output: err.message,
387
+ toolCallCount: parser.getToolCallCount(),
388
+ tokensIn,
389
+ tokensOut,
390
+ });
201
391
  });
202
- activeChildProcesses.add(child);
203
392
  });
204
393
  }
205
394
  function killAllChildProcesses() {
@@ -246,25 +435,31 @@ Before proceeding:
246
435
  Press Ctrl+C to cancel, or re-run with --yes to skip this warning.
247
436
  `);
248
437
  }
249
- async function waitForConfirmation() {
438
+ function waitForConfirmation() {
250
439
  return new Promise((resolve) => {
440
+ // Non-TTY (piped input) — read from stdin instead of rejecting
251
441
  process.stdout.write('Continue? [y/N] ');
252
442
  process.stdin.setEncoding('utf8');
253
443
  process.stdin.once('data', (data) => {
254
444
  const answer = data.trim().toLowerCase();
255
445
  resolve(answer === 'y' || answer === 'yes');
256
446
  });
257
- // If stdin is not a TTY (piped), reject
258
- if (!process.stdin.isTTY) {
259
- resolve(false);
260
- }
447
+ // If stdin ends without data (empty pipe), default to no
448
+ process.stdin.once('end', () => resolve(false));
261
449
  });
262
450
  }
263
451
  // ---------------------------------------------------------------------------
264
452
  // runPipeline — main entry point
265
453
  // ---------------------------------------------------------------------------
266
454
  export async function runPipeline(client, args) {
455
+ // Handle subcommands
456
+ if (args[0] === 'init') {
457
+ const { runPipelineInit } = await import('./pipeline-init.js');
458
+ await runPipelineInit();
459
+ return;
460
+ }
267
461
  const opts = parseArgs(args);
462
+ currentBoardId = opts.boardId;
268
463
  // Safety confirmation
269
464
  if (!opts.yes && !opts.dryRun) {
270
465
  printSafetyWarning();
@@ -274,6 +469,30 @@ export async function runPipeline(client, args) {
274
469
  return;
275
470
  }
276
471
  }
472
+ // Validate gate file
473
+ const gateFilePath = join(process.cwd(), 'pipeline.gates.yaml');
474
+ let gateConfig;
475
+ if (!existsSync(gateFilePath)) {
476
+ console.error(`Error: pipeline.gates.yaml not found in ${process.cwd()}`);
477
+ console.error('Run "kantban pipeline init" to generate a starter gate file.');
478
+ process.exit(1);
479
+ }
480
+ try {
481
+ const gateYaml = readFileSync(gateFilePath, 'utf-8');
482
+ gateConfig = parseGateConfig(gateYaml);
483
+ console.log(`Gate config loaded: ${gateConfig.default.length} default gate(s)`);
484
+ }
485
+ catch (err) {
486
+ const message = err instanceof Error ? err.message : String(err);
487
+ console.error(`Error: Invalid pipeline.gates.yaml: ${message}`);
488
+ process.exit(1);
489
+ }
490
+ // Create cost tracker if budget is configured
491
+ const costTracker = gateConfig.settings?.budget
492
+ ? new PipelineCostTracker(crypto.randomUUID(), gateConfig.settings.budget)
493
+ : undefined;
494
+ // Gate snapshot store — per-ticket gate history for gutter detection and advisor
495
+ const gateSnapshotStore = new GateSnapshotStore();
277
496
  // Resolve project ID
278
497
  let projectId = process.env['KANTBAN_PROJECT_ID'] ?? '';
279
498
  if (!projectId) {
@@ -294,28 +513,171 @@ export async function runPipeline(client, args) {
294
513
  const logBaseDir = join(homedir(), '.kantban', 'pipelines');
295
514
  const logger = new PipelineLogger(logBaseDir, opts.boardId);
296
515
  logger.pruneOldLogs(opts.logRetention);
516
+ // Declared early so closures in deps can capture it by reference before initialization
517
+ let runMemory = null;
518
+ // Stable session ID for all pipeline events in this run
519
+ const pipelineSessionId = crypto.randomUUID();
520
+ const eventEmitter = new PipelineEventEmitter(client.baseUrl, projectId, client.token, opts.boardId, pipelineSessionId);
297
521
  // Wire up OrchestratorDeps
298
522
  const deps = {
299
523
  fetchBoardScope: (boardId) => client.get(`/projects/${projectId}/pipeline-context`, { boardId }),
300
524
  fetchColumnScope: (columnId) => client.get(`/projects/${projectId}/pipeline-context`, { columnId }),
301
- startLoop: (ticketId, columnId, config) => {
525
+ startLoop: async (ticketId, columnId, config) => {
302
526
  // Apply CLI overrides
303
527
  const effectiveConfig = {
304
528
  ...config,
305
529
  ...(opts.maxIterations !== null && { maxIterations: opts.maxIterations }),
306
530
  ...(opts.maxBudget !== null && { maxBudgetUsd: opts.maxBudget }),
307
- ...(opts.model !== null && { modelPreference: opts.model }),
531
+ ...(opts.model !== null && { model: opts.model }),
308
532
  };
533
+ // Capture run memory ref before async work so closures below see the same instance
534
+ const mem = runMemory;
535
+ // Resolve column name once upfront so we can select the right MCP config.
536
+ // This also pre-warms cachedColumnName for onPostIterationGates below.
537
+ const colScopeForName = await client.get(`/projects/${projectId}/pipeline-context`, { columnId });
538
+ const resolvedColumnName = colScopeForName.column.name;
539
+ // Choose MCP config: gate-proxy variant when gates are configured for this column,
540
+ // global variant otherwise.
541
+ const columnGates = resolveGatesForColumn(gateConfig, resolvedColumnName);
542
+ const effectiveMcpConfigPath = columnGates.length > 0
543
+ ? generateGateProxyMcpConfig(client.baseUrl, client.token, opts.boardId, gateFilePath, columnId, resolvedColumnName, projectId)
544
+ : mcpConfigPath;
309
545
  const loopDeps = {
310
546
  fetchTicketContext: (tid) => client.get(`/projects/${projectId}/pipeline-context`, { ticketId: tid }),
311
547
  fetchColumnContext: (cid) => client.get(`/projects/${projectId}/pipeline-context`, { columnId: cid }),
312
548
  fetchFingerprint: (tid) => client.getFingerprint(projectId, tid),
313
549
  invokeClaudeP,
314
- mcpConfigPath,
550
+ mcpConfigPath: effectiveMcpConfigPath,
315
551
  projectId,
316
552
  log: (msg) => {
317
553
  logger.orchestrator(`[${ticketId}] ${msg}`);
318
554
  },
555
+ // Run memory enrichment — inject accumulated discoveries into each iteration prompt
556
+ fetchRunMemoryContent: mem
557
+ ? async () => {
558
+ const content = await mem.getContent();
559
+ const lines = content.split('\n');
560
+ if (lines.length > 500) {
561
+ return lines.slice(-500).join('\n');
562
+ }
563
+ return content;
564
+ }
565
+ : undefined,
566
+ // Lookahead enrichment — fetch next-column prompt doc so agent can anticipate exit criteria
567
+ fetchLookaheadDocument: effectiveConfig.lookaheadColumnId
568
+ ? async () => {
569
+ const colScope = await client.get(`/projects/${projectId}/pipeline-context`, { columnId: effectiveConfig.lookaheadColumnId });
570
+ if (!colScope.prompt_document)
571
+ return undefined;
572
+ return { title: colScope.prompt_document.title, content: colScope.prompt_document.content };
573
+ }
574
+ : undefined,
575
+ // Pipeline event emission — forward stream/session events to WS for browser delivery
576
+ onStreamEvent: deps.emitPipelineEvent
577
+ ? (event, context) => {
578
+ deps.emitPipelineEvent({
579
+ type: 'pipeline:stream',
580
+ payload: {
581
+ boardId: opts.boardId,
582
+ ticketId: context.ticketId,
583
+ columnId: context.columnId,
584
+ runId: context.runId,
585
+ sessionId: pipelineSessionId,
586
+ event: event,
587
+ },
588
+ });
589
+ }
590
+ : undefined,
591
+ onSessionStart: deps.emitPipelineEvent
592
+ ? (meta) => {
593
+ deps.emitPipelineEvent({
594
+ type: 'pipeline:session-start',
595
+ payload: {
596
+ boardId: opts.boardId,
597
+ ticketId,
598
+ columnId,
599
+ runId: meta.runId,
600
+ sessionId: pipelineSessionId,
601
+ model: meta.model,
602
+ invocationType: 'heavy',
603
+ iteration: meta.iteration,
604
+ },
605
+ });
606
+ }
607
+ : undefined,
608
+ onSessionEnd: (meta) => {
609
+ // Cost tracking — always active
610
+ costTracker?.record({
611
+ ticketId,
612
+ columnId,
613
+ model: effectiveConfig.model ?? 'default',
614
+ tokensIn: meta.tokensIn,
615
+ tokensOut: meta.tokensOut,
616
+ type: 'heavy',
617
+ });
618
+ // Pipeline event emission — only when WS client is connected
619
+ if (deps.emitPipelineEvent) {
620
+ deps.emitPipelineEvent({
621
+ type: 'pipeline:session-end',
622
+ payload: {
623
+ boardId: opts.boardId,
624
+ runId: meta.runId,
625
+ exitReason: null,
626
+ tokensIn: meta.tokensIn,
627
+ tokensOut: meta.tokensOut,
628
+ toolCallCount: meta.toolCallCount,
629
+ durationMs: meta.durationMs,
630
+ },
631
+ });
632
+ }
633
+ },
634
+ };
635
+ // Wire checkpoint callback when setFieldValue is available
636
+ if (deps.setFieldValue) {
637
+ effectiveConfig.onCheckpoint = async (tid, checkpoint) => {
638
+ try {
639
+ await deps.setFieldValue(tid, 'loop_checkpoint', checkpoint);
640
+ }
641
+ catch {
642
+ // Fire-and-forget
643
+ }
644
+ };
645
+ }
646
+ // Wire stuck detection — invokes a lightweight LLM to classify trajectory mid-run
647
+ if (effectiveConfig.stuckDetection) {
648
+ effectiveConfig.invokeStuckDetection = async (input) => {
649
+ const prompt = composeStuckDetectionPrompt(input);
650
+ const { exitCode, output } = await invokeClaudeP(prompt, {
651
+ mcpConfigPath,
652
+ model: 'haiku',
653
+ maxBudgetUsd: 0.01,
654
+ tools: '',
655
+ includeMcpConfig: false,
656
+ });
657
+ if (exitCode !== 0) {
658
+ throw new Error(`Stuck detection call failed (exit ${String(exitCode)})`);
659
+ }
660
+ return parseStuckDetectionResponse(output);
661
+ };
662
+ }
663
+ // Wire post-iteration gate runner — runs all gates after each Claude invocation
664
+ // resolvedColumnName is already fetched above — no per-iteration API call needed.
665
+ effectiveConfig.onPostIterationGates = async (tid, iteration) => {
666
+ const gates = resolveGatesForColumn(gateConfig, resolvedColumnName);
667
+ if (gates.length === 0) {
668
+ // No gates — return a synthetic all-pass snapshot
669
+ return gateSnapshotStore.record(tid, iteration, []);
670
+ }
671
+ const runOpts = {};
672
+ if (gateConfig.settings?.cwd)
673
+ runOpts.cwd = gateConfig.settings.cwd;
674
+ if (gateConfig.settings?.env)
675
+ runOpts.env = gateConfig.settings.env;
676
+ if (gateConfig.settings?.total_timeout) {
677
+ runOpts.totalTimeoutMs = parseTimeout(gateConfig.settings.total_timeout);
678
+ }
679
+ const results = await runGates(gates, runOpts);
680
+ return gateSnapshotStore.record(tid, iteration, results);
319
681
  };
320
682
  const loop = new RalphLoop(ticketId, columnId, effectiveConfig, loopDeps);
321
683
  // Track active loops for graceful shutdown
@@ -325,13 +687,133 @@ export async function runPipeline(client, args) {
325
687
  });
326
688
  },
327
689
  createComment: (ticketId, body) => client.post(`/projects/${projectId}/tickets/${ticketId}/comments`, { body }),
328
- createSignal: (ticketId, body) => client.post(`/projects/${projectId}/tickets/${ticketId}/signals`, { body }),
690
+ createSignal: (ticketId, body) => client.post(`/projects/${projectId}/signals`, {
691
+ scopeType: 'ticket',
692
+ scopeId: ticketId,
693
+ content: body,
694
+ }),
695
+ createColumnSignal: (columnId, body) => client.post(`/projects/${projectId}/signals`, {
696
+ scopeType: 'column',
697
+ scopeId: columnId,
698
+ content: body,
699
+ }),
329
700
  claimTicket: (ticketId) => client.claimTicket(projectId, ticketId),
330
701
  fetchBlockedTickets: (ticketId) => client.get(`/projects/${projectId}/tickets/${ticketId}/blocked-tickets`),
331
702
  hasUnresolvedBlockers: async (ticketId) => {
332
703
  const blockers = await client.get(`/projects/${projectId}/tickets/${ticketId}/unresolved-blockers`);
333
704
  return blockers.length > 0;
334
705
  },
706
+ dispatchLightCall: async (ticketId, columnId) => {
707
+ // Fetch minimal context for light call (parallel)
708
+ const [ticketCtx, colScope, boardScope] = await Promise.all([
709
+ client.get(`/projects/${projectId}/pipeline-context`, { ticketId }),
710
+ client.get(`/projects/${projectId}/pipeline-context`, { columnId }),
711
+ client.get(`/projects/${projectId}/pipeline-context`, { boardId: opts.boardId }),
712
+ ]);
713
+ const lightCtx = {
714
+ ticketId,
715
+ ticketNumber: ticketCtx.ticket.ticket_number,
716
+ ticketTitle: ticketCtx.ticket.title,
717
+ ...(ticketCtx.ticket.description ? { ticketDescription: ticketCtx.ticket.description } : {}),
718
+ columnName: colScope.column.name,
719
+ fieldValues: ticketCtx.field_values,
720
+ toolPrefix: colScope.tool_prefix,
721
+ projectId,
722
+ ...(colScope.transition_rules ? { transitionRules: colScope.transition_rules } : {}),
723
+ ...(boardScope.columns && boardScope.columns.length > 0 && {
724
+ availableColumns: boardScope.columns.map((col) => ({ id: col.id, name: col.name })),
725
+ }),
726
+ };
727
+ const prompt = composeLightPrompt(lightCtx);
728
+ logger.orchestrator(`[${ticketId}] Light call: composing prompt for column "${colScope.column.name}"`);
729
+ const { exitCode, output, tokensIn, tokensOut } = await invokeClaudeP(prompt, {
730
+ mcpConfigPath,
731
+ model: 'haiku',
732
+ maxBudgetUsd: 0.01,
733
+ maxTurns: 3,
734
+ tools: '', // Strip all built-in tools
735
+ includeMcpConfig: false, // No MCP tools either
736
+ });
737
+ costTracker?.record({ ticketId, columnId, model: 'haiku', tokensIn, tokensOut, type: 'light' });
738
+ if (exitCode !== 0) {
739
+ throw new Error(`Light call exited with code ${exitCode}: ${output.slice(0, 200)}`);
740
+ }
741
+ const response = parseLightResponse(output);
742
+ logger.orchestrator(`[${ticketId}] Light call result: ${response.action} — ${response.reason}`);
743
+ return response;
744
+ },
745
+ // Advisor invocation — recovers from failure exits (stalled/error/max_iterations)
746
+ invokeAdvisor: async (input) => {
747
+ const prompt = composeAdvisorPrompt(input);
748
+ logger.orchestrator(`[${input.ticketId}] Advisor: invoking for ${input.exitReason}`);
749
+ const { exitCode, output, tokensIn, tokensOut } = await invokeClaudeP(prompt, {
750
+ mcpConfigPath,
751
+ model: 'haiku',
752
+ maxBudgetUsd: 0.01,
753
+ tools: '',
754
+ includeMcpConfig: false,
755
+ });
756
+ costTracker?.record({ ticketId: input.ticketId, columnId: '', model: 'haiku', tokensIn, tokensOut, type: 'advisor' });
757
+ if (exitCode !== 0) {
758
+ throw new Error(`Advisor call exited with code ${exitCode}: ${output.slice(0, 200)}`);
759
+ }
760
+ const response = parseAdvisorResponse(output);
761
+ logger.orchestrator(`[${input.ticketId}] Advisor: ${response.action} — ${response.reason}`);
762
+ return response;
763
+ },
764
+ // Field value management — used for debt items, checkpoint writes, and escalation metadata
765
+ setFieldValue: async (ticketId, fieldName, value) => {
766
+ await client.put(`/projects/${projectId}/tickets/${ticketId}/field-values`, {
767
+ values: { [fieldName]: value },
768
+ });
769
+ },
770
+ getFieldValues: async (ticketId) => {
771
+ const ctx = await client.get(`/projects/${projectId}/pipeline-context`, { ticketId });
772
+ return ctx.field_values;
773
+ },
774
+ // Ticket management for advisor actions (ESCALATE, SPLIT_TICKET)
775
+ moveTicketToColumn: async (ticketId, columnId, handoff) => {
776
+ await client.patch(`/projects/${projectId}/tickets/${ticketId}/move`, {
777
+ column_id: columnId,
778
+ handoff,
779
+ });
780
+ },
781
+ createTickets: async (parentTicketId, specs) => {
782
+ const ids = [];
783
+ for (const spec of specs) {
784
+ const result = await client.post(`/projects/${projectId}/tickets`, { title: spec.title, description: spec.description, parent_id: parentTicketId });
785
+ ids.push(result.id);
786
+ }
787
+ return ids;
788
+ },
789
+ archiveTicket: async (ticketId) => {
790
+ await client.post(`/projects/${projectId}/tickets/${ticketId}/archive`, {});
791
+ },
792
+ // Run memory append — closure captures runMemory by reference (set after initialization)
793
+ appendRunMemory: (section, content) => runMemory ? runMemory.append(section, content) : Promise.resolve(),
794
+ cleanupWorktree: (name) => cleanupWorktree(name),
795
+ // Pipeline event emission — wsClient captured by reference (set later before any loops run)
796
+ emitPipelineEvent: (event) => {
797
+ wsClient?.send(event);
798
+ },
799
+ boardId: opts.boardId,
800
+ eventEmitter,
801
+ costTracker,
802
+ gateSnapshotStore,
803
+ invokeReplanner: async (state) => {
804
+ const prompt = composeReplannerPrompt(state);
805
+ const { exitCode, output, tokensIn, tokensOut } = await invokeClaudeP(prompt, {
806
+ mcpConfigPath,
807
+ model: 'haiku',
808
+ maxBudgetUsd: 0.01,
809
+ tools: '',
810
+ includeMcpConfig: false,
811
+ });
812
+ costTracker?.record({ ticketId: 'replanner', columnId: 'pipeline', model: 'haiku', tokensIn, tokensOut, type: 'orchestrator' });
813
+ if (exitCode !== 0)
814
+ throw new Error(`Replanner failed`);
815
+ return parseReplannerResponse(output);
816
+ },
335
817
  };
336
818
  // Create orchestrator
337
819
  const orchestrator = new PipelineOrchestrator(opts.boardId, projectId, deps);
@@ -346,12 +828,54 @@ export async function runPipeline(client, args) {
346
828
  console.error(`Error: Failed to initialize pipeline: ${message}`);
347
829
  console.error('Check that the board exists, has pipeline columns configured, and the API is reachable.');
348
830
  cleanupMcpConfig(mcpConfigPath);
831
+ cleanupGateProxyConfigs(pidDir(opts.boardId));
349
832
  process.exit(1);
350
833
  }
351
834
  const columnIds = orchestrator.pipelineColumnIds;
835
+ // Initialize run memory if any pipeline column has run_memory enabled
836
+ try {
837
+ for (const colId of columnIds) {
838
+ const colScope = await client.get(`/projects/${projectId}/pipeline-context`, { columnId: colId });
839
+ if (colScope.agent_config?.run_memory) {
840
+ // Fetch board name for the run memory document title
841
+ const boardScope = await client.get(`/projects/${projectId}/pipeline-context`, { boardId: opts.boardId });
842
+ // Resolve a space to store the run memory document — use first available space
843
+ const spaces = await client.get(`/projects/${projectId}/spaces`);
844
+ const spaceId = spaces[0]?.id;
845
+ if (!spaceId) {
846
+ logger.orchestrator('Run memory: no spaces found in project — skipping run memory');
847
+ console.warn('Warning: Run memory requires at least one document space in the project — skipping.');
848
+ break;
849
+ }
850
+ runMemory = new RunMemory(opts.boardId, boardScope.board.name, {
851
+ createDocument: async (content, title) => {
852
+ const result = await client.post(`/projects/${projectId}/spaces/${spaceId}/documents`, { title, content });
853
+ return result.id;
854
+ },
855
+ getDocument: async (docId) => {
856
+ const doc = await client.get(`/projects/${projectId}/documents/${docId}`);
857
+ return doc.content;
858
+ },
859
+ updateDocument: async (docId, content) => {
860
+ await client.patch(`/projects/${projectId}/documents/${docId}`, { content });
861
+ },
862
+ });
863
+ await runMemory.initialize();
864
+ logger.orchestrator(`Run memory initialized (doc=${runMemory.documentId ?? 'none'})`);
865
+ console.log(`Run memory enabled (document: ${runMemory.documentId ?? 'none'})`);
866
+ break;
867
+ }
868
+ }
869
+ }
870
+ catch (err) {
871
+ const msg = err instanceof Error ? err.message : String(err);
872
+ logger.orchestrator(`Run memory init failed (non-fatal): ${msg}`);
873
+ console.error(`Warning: Run memory initialization failed: ${msg}`);
874
+ }
352
875
  if (columnIds.length === 0) {
353
876
  console.log('No pipeline columns found (columns need has_prompt=true and type !== "done").');
354
877
  cleanupMcpConfig(mcpConfigPath);
878
+ cleanupGateProxyConfigs(pidDir(opts.boardId));
355
879
  return;
356
880
  }
357
881
  console.log(`Discovered ${String(columnIds.length)} pipeline column(s).`);
@@ -372,6 +896,7 @@ export async function runPipeline(client, args) {
372
896
  console.log(`MCP config: ${mcpConfigPath}`);
373
897
  console.log('\n[Dry run -- no agents started]');
374
898
  cleanupMcpConfig(mcpConfigPath);
899
+ cleanupGateProxyConfigs(pidDir(opts.boardId));
375
900
  return;
376
901
  }
377
902
  // --- Live mode ---
@@ -383,10 +908,15 @@ export async function runPipeline(client, args) {
383
908
  shutdownInProgress = true;
384
909
  console.log(`\nReceived ${signal}. Shutting down gracefully...`);
385
910
  logger.orchestrator(`Shutdown initiated (${signal})`);
386
- wsClient?.stop();
387
911
  if (rescanTimer)
388
912
  clearInterval(rescanTimer);
389
913
  eventQueue?.stop();
914
+ // Notify browser clients that the pipeline is stopping (before closing WS)
915
+ wsClient?.send({ type: 'pipeline:stopped', payload: { boardId: opts.boardId } });
916
+ // Give the WS a moment to flush the stop notification
917
+ await new Promise(r => setTimeout(r, 200));
918
+ await eventEmitter.close();
919
+ wsClient?.stop();
390
920
  for (const loop of activeRalphLoops) {
391
921
  loop.stop();
392
922
  }
@@ -400,14 +930,39 @@ export async function runPipeline(client, args) {
400
930
  console.error(`Warning: ${String(activeChildProcesses.size)} child process(es) did not exit. Sending SIGKILL...`);
401
931
  for (const child of activeChildProcesses) {
402
932
  try {
403
- if (child.pid)
404
- process.kill(-child.pid, 'SIGKILL');
933
+ if (child.pid) {
934
+ try {
935
+ process.kill(-child.pid, 'SIGKILL');
936
+ }
937
+ catch {
938
+ try {
939
+ child.kill('SIGKILL');
940
+ }
941
+ catch { /* already dead */ }
942
+ }
943
+ }
944
+ else {
945
+ try {
946
+ child.kill('SIGKILL');
947
+ }
948
+ catch { /* already dead */ }
949
+ }
950
+ }
951
+ catch {
952
+ // Process already exited
405
953
  }
406
- catch { /* ignore */ }
407
954
  }
408
955
  }
956
+ // Print cost report
957
+ if (costTracker) {
958
+ console.log('\n--- Pipeline Cost Report ---');
959
+ console.log(costTracker.generateReport(gateConfig.settings?.pricing));
960
+ }
409
961
  cleanupMcpConfig(mcpConfigPath);
962
+ cleanupGateProxyConfigs(pidDir(opts.boardId));
963
+ killReaper(reaperPidPath);
410
964
  removePidFile(opts.boardId);
965
+ removeChildManifest(opts.boardId);
411
966
  logger.orchestrator('Shutdown complete');
412
967
  console.log('Pipeline stopped.');
413
968
  process.exit(0);
@@ -424,11 +979,28 @@ export async function runPipeline(client, args) {
424
979
  process.exit(1);
425
980
  });
426
981
  });
982
+ process.on('SIGHUP', () => {
983
+ shutdown('SIGHUP').catch((err) => {
984
+ console.error('Error during shutdown:', err);
985
+ process.exit(1);
986
+ });
987
+ });
427
988
  // Clean up orphaned processes from a previous run before starting
428
989
  cleanupOrphanedProcesses(opts.boardId);
429
990
  // Write PID file
430
991
  writePidFile(opts.boardId);
431
992
  logger.orchestrator(`PID file written: ${String(process.pid)}`);
993
+ // Spawn watchdog reaper — kills children if orchestrator dies unexpectedly
994
+ const reaperPidPath = join(pidDir(opts.boardId), 'reaper.pid');
995
+ const reaperProcess = spawnReaper({
996
+ orchestratorPid: process.pid,
997
+ manifestPath: childManifestPath(opts.boardId),
998
+ pidFilePath: pidFilePath(opts.boardId),
999
+ reaperPidPath,
1000
+ mcpConfigPath,
1001
+ pipelineDir: pidDir(opts.boardId),
1002
+ });
1003
+ logger.orchestrator(`Watchdog reaper spawned (PID ${String(reaperProcess.pid ?? 'unknown')})`);
432
1004
  // Set up event queue and WS client
433
1005
  let eventQueue = null;
434
1006
  let wsClient = null;
@@ -446,8 +1018,6 @@ export async function runPipeline(client, args) {
446
1018
  boardId: opts.boardId,
447
1019
  projectId,
448
1020
  onEvent: (wsEvent) => {
449
- // Log ALL WS events for debugging
450
- logger.orchestrator(`WS raw event: ${wsEvent.type} payload=${JSON.stringify(wsEvent.payload)}`);
451
1021
  // Map WS events to pipeline events
452
1022
  const payload = wsEvent.payload;
453
1023
  // ticket:moved and ticket:created nest the id inside payload.ticket
@@ -458,6 +1028,17 @@ export async function runPipeline(client, args) {
458
1028
  // ticket:created has payload.columnId; ticket:moved has the new column in ticket.column_id
459
1029
  const columnId = (typeof payload['columnId'] === 'string' ? payload['columnId'] : null) ??
460
1030
  (ticket && typeof ticket['column_id'] === 'string' ? ticket['column_id'] : null);
1031
+ // Handle firing constraint changes — refresh constraint caches
1032
+ if (wsEvent.type === 'firing_constraint:created' ||
1033
+ wsEvent.type === 'firing_constraint:updated' ||
1034
+ wsEvent.type === 'firing_constraint:deleted') {
1035
+ logger.orchestrator(`WS event: ${wsEvent.type} — refreshing constraint caches`);
1036
+ void orchestrator.refreshConstraints().catch((err) => {
1037
+ const msg = err instanceof Error ? err.message : String(err);
1038
+ logger.orchestrator(`Constraint cache refresh failed: ${msg}`);
1039
+ });
1040
+ return;
1041
+ }
461
1042
  if (!ticketId)
462
1043
  return;
463
1044
  const eventType = wsEvent.type;
@@ -510,11 +1091,23 @@ export async function runPipeline(client, args) {
510
1091
  logger.orchestrator('Once mode: waiting for loops to complete');
511
1092
  // Poll until no active loops or queued work remain
512
1093
  await waitForAllLoops(orchestrator);
1094
+ // Print cost report
1095
+ if (costTracker) {
1096
+ console.log('\n--- Pipeline Cost Report ---');
1097
+ console.log(costTracker.generateReport(gateConfig.settings?.pricing));
1098
+ }
1099
+ // Notify browser clients that the pipeline has finished
1100
+ wsClient.send({ type: 'pipeline:stopped', payload: { boardId: opts.boardId } });
1101
+ await new Promise(r => setTimeout(r, 200));
513
1102
  // Cleanup
1103
+ await eventEmitter.close();
514
1104
  wsClient.stop();
515
1105
  eventQueue.stop();
516
1106
  cleanupMcpConfig(mcpConfigPath);
1107
+ cleanupGateProxyConfigs(pidDir(opts.boardId));
1108
+ killReaper(reaperPidPath);
517
1109
  removePidFile(opts.boardId);
1110
+ removeChildManifest(opts.boardId);
518
1111
  logger.orchestrator('Once mode complete. Exiting.');
519
1112
  console.log('All loops complete. Pipeline exiting.');
520
1113
  }
@@ -568,7 +1161,7 @@ async function waitForAllLoops(orchestrator, timeoutMs = 4 * 60 * 60 * 1000) {
568
1161
  return;
569
1162
  }
570
1163
  await new Promise((resolve) => setTimeout(resolve, 1000));
571
- if (activeRalphLoops.size === 0 && orchestrator.activeLoopCount === 0 && !orchestrator.hasQueuedWork) {
1164
+ if (activeRalphLoops.size === 0 && orchestrator.activeLoopCount === 0 && !orchestrator.hasActiveQueuedWork) {
572
1165
  consecutiveIdle++;
573
1166
  }
574
1167
  else {