@thispointon/kondi-chat 0.1.2

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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/bin/kondi-chat +56 -0
  4. package/bin/kondi-chat.js +72 -0
  5. package/package.json +55 -0
  6. package/scripts/demo.tape +49 -0
  7. package/scripts/postinstall.cjs +103 -0
  8. package/src/audit/analytics.ts +261 -0
  9. package/src/audit/ledger.ts +253 -0
  10. package/src/audit/telemetry.ts +165 -0
  11. package/src/cli/backend.ts +675 -0
  12. package/src/cli/commands.ts +419 -0
  13. package/src/cli/help.ts +182 -0
  14. package/src/cli/submit-helpers.ts +159 -0
  15. package/src/cli/submit.ts +539 -0
  16. package/src/cli/wizard.ts +121 -0
  17. package/src/context/bootstrap.ts +138 -0
  18. package/src/context/budget.ts +100 -0
  19. package/src/context/manager.ts +666 -0
  20. package/src/context/memory.ts +160 -0
  21. package/src/context/preflight.ts +176 -0
  22. package/src/context/project-brain.ts +101 -0
  23. package/src/context/receipts.ts +108 -0
  24. package/src/context/skills.ts +154 -0
  25. package/src/context/symbol-index.ts +240 -0
  26. package/src/council/profiles.ts +137 -0
  27. package/src/council/tool.ts +138 -0
  28. package/src/council-engine/cli/council-artifacts.ts +230 -0
  29. package/src/council-engine/cli/council-config.ts +178 -0
  30. package/src/council-engine/cli/council-session-export.ts +116 -0
  31. package/src/council-engine/cli/kondi.ts +98 -0
  32. package/src/council-engine/cli/llm-caller.ts +229 -0
  33. package/src/council-engine/cli/localStorage-shim.ts +119 -0
  34. package/src/council-engine/cli/node-platform.ts +68 -0
  35. package/src/council-engine/cli/run-council.ts +481 -0
  36. package/src/council-engine/cli/run-pipeline.ts +772 -0
  37. package/src/council-engine/cli/session-export.ts +153 -0
  38. package/src/council-engine/configs/councils/analysis.json +101 -0
  39. package/src/council-engine/configs/councils/code-planning.json +86 -0
  40. package/src/council-engine/configs/councils/coding.json +89 -0
  41. package/src/council-engine/configs/councils/debate.json +97 -0
  42. package/src/council-engine/configs/councils/solo-claude.json +34 -0
  43. package/src/council-engine/configs/councils/solo-gpt.json +34 -0
  44. package/src/council-engine/council/coding-orchestrator.ts +1205 -0
  45. package/src/council-engine/council/context-bootstrap.ts +147 -0
  46. package/src/council-engine/council/context-inspection.ts +42 -0
  47. package/src/council-engine/council/context-store.ts +763 -0
  48. package/src/council-engine/council/deliberation-orchestrator.ts +2762 -0
  49. package/src/council-engine/council/factory.ts +164 -0
  50. package/src/council-engine/council/index.ts +201 -0
  51. package/src/council-engine/council/ledger-store.ts +438 -0
  52. package/src/council-engine/council/prompts.ts +1689 -0
  53. package/src/council-engine/council/storage-cleanup.ts +164 -0
  54. package/src/council-engine/council/store.ts +1110 -0
  55. package/src/council-engine/council/synthesis.ts +291 -0
  56. package/src/council-engine/council/types.ts +845 -0
  57. package/src/council-engine/council/validation.ts +613 -0
  58. package/src/council-engine/pipeline/build-detect.ts +73 -0
  59. package/src/council-engine/pipeline/executor.ts +1048 -0
  60. package/src/council-engine/pipeline/index.ts +9 -0
  61. package/src/council-engine/pipeline/install-detect.ts +84 -0
  62. package/src/council-engine/pipeline/memory-store.ts +182 -0
  63. package/src/council-engine/pipeline/output-parsers.ts +146 -0
  64. package/src/council-engine/pipeline/run-output.ts +149 -0
  65. package/src/council-engine/pipeline/session-import.ts +177 -0
  66. package/src/council-engine/pipeline/store.ts +753 -0
  67. package/src/council-engine/pipeline/test-detect.ts +82 -0
  68. package/src/council-engine/pipeline/types.ts +401 -0
  69. package/src/council-engine/services/deliberationSummary.ts +114 -0
  70. package/src/council-engine/tsconfig.json +16 -0
  71. package/src/council-engine/types/mcp.ts +122 -0
  72. package/src/council-engine/utils/filterTools.ts +73 -0
  73. package/src/engine/apply.ts +238 -0
  74. package/src/engine/checkpoints.ts +237 -0
  75. package/src/engine/consultants.ts +347 -0
  76. package/src/engine/diff.ts +171 -0
  77. package/src/engine/errors.ts +102 -0
  78. package/src/engine/git-tools.ts +246 -0
  79. package/src/engine/hooks.ts +181 -0
  80. package/src/engine/loop-guard.ts +155 -0
  81. package/src/engine/permissions.ts +293 -0
  82. package/src/engine/pipeline.ts +376 -0
  83. package/src/engine/sub-agents.ts +133 -0
  84. package/src/engine/task-card.ts +185 -0
  85. package/src/engine/task-router.ts +256 -0
  86. package/src/engine/task-store.ts +86 -0
  87. package/src/engine/tools.ts +783 -0
  88. package/src/engine/verify.ts +111 -0
  89. package/src/mcp/client.ts +225 -0
  90. package/src/mcp/config.ts +120 -0
  91. package/src/mcp/tool-manager.ts +192 -0
  92. package/src/mcp/types.ts +61 -0
  93. package/src/providers/llm-caller.ts +943 -0
  94. package/src/providers/rate-limiter.ts +238 -0
  95. package/src/router/NOTES.md +28 -0
  96. package/src/router/collector.ts +474 -0
  97. package/src/router/embeddings.ts +286 -0
  98. package/src/router/index.ts +299 -0
  99. package/src/router/intent-router.ts +225 -0
  100. package/src/router/nn-router.ts +205 -0
  101. package/src/router/profiles.ts +309 -0
  102. package/src/router/registry.ts +565 -0
  103. package/src/router/rules.ts +274 -0
  104. package/src/router/train.py +408 -0
  105. package/src/session/store.ts +211 -0
  106. package/src/test-utils/mock-llm.ts +39 -0
  107. package/src/types.ts +322 -0
  108. package/src/web/manager.ts +311 -0
@@ -0,0 +1,1048 @@
1
+ /**
2
+ * Pipeline Executor
3
+ * Runs pipelines: sequential stages, parallel steps within stages.
4
+ * Council steps (planning, coding, decisioning, execution, etc.) create councils.
5
+ * Script steps run shell commands. Condition steps evaluate expressions.
6
+ * Gate steps pause for user approval.
7
+ */
8
+
9
+ import type {
10
+ Pipeline,
11
+ PipelineStage,
12
+ PipelineStep,
13
+ StepArtifact,
14
+ StepMeta,
15
+ RunManifest,
16
+ CouncilStepConfig,
17
+ LlmStepConfig,
18
+ GateStepConfig,
19
+ ScriptStepConfig,
20
+ ConditionStepConfig,
21
+ } from './types';
22
+ import { migrateLlmConfig } from './types';
23
+
24
+ import { pipelineStore } from './store';
25
+ import { councilStore } from '../council/store';
26
+ import { createCouncilFromSetup } from '../council/factory';
27
+ import { DeliberationOrchestrator } from '../council/deliberation-orchestrator';
28
+ import { CodingOrchestrator } from '../council/coding-orchestrator';
29
+ import { getDecision, getLatestOutput } from '../council/context-store';
30
+ import { stripCompletedCouncil } from '../council/storage-cleanup';
31
+ import { buildAbbreviatedSummary } from '../services/deliberationSummary';
32
+ import type { Persona } from '../council/types';
33
+ import type { AgentInvocation, AgentResponse } from '../council/deliberation-orchestrator';
34
+ import type { MCPTool } from '../types/mcp';
35
+ import { verifyRequiredTools } from '../utils/filterTools';
36
+
37
+ import type { MemoryContext } from './memory-store';
38
+ import { buildMemoryContext, appendEntry, getNextRunNumber } from './memory-store';
39
+ import { sanitizeFolderName } from './run-output';
40
+ import {
41
+ buildRunDirName,
42
+ getRunsBaseDir,
43
+ buildStepOutputDir,
44
+ writeStepOutput,
45
+ writeRunManifest,
46
+ pruneOldRuns,
47
+ } from './run-output';
48
+
49
+ // ============================================================================
50
+ // Callback Types
51
+ // ============================================================================
52
+
53
+ export interface PipelineExecutorCallbacks {
54
+ /** Same invokeAgent used by DeliberationOrchestrator */
55
+ invokeAgent: (invocation: AgentInvocation, persona: Persona) => Promise<AgentResponse>;
56
+
57
+ /** Returns all currently available MCP tools (for pre-flight tool checks) */
58
+ getAvailableTools?: () => Map<string, { serverId: string; tools: MCPTool[] }>;
59
+
60
+ onStageStart?: (stageIndex: number) => void;
61
+ onStageComplete?: (stageIndex: number) => void;
62
+ onStepStart?: (stepId: string) => void;
63
+ onStepComplete?: (stepId: string, artifact: StepArtifact) => void;
64
+ onStepError?: (stepId: string, error: string) => void;
65
+ onGateWaiting?: (stepId: string, prompt: string) => Promise<boolean>;
66
+ onCouncilCreated?: (stepId: string, councilId: string) => void;
67
+ onAgentThinkingStart?: (persona: Persona, startedAt: number, prompt?: string) => void;
68
+ onAgentThinkingEnd?: (persona: Persona) => void;
69
+ }
70
+
71
+ // ============================================================================
72
+ // Platform Adapter (abstracts Tauri / Node.js)
73
+ // ============================================================================
74
+
75
+ export interface PlatformAdapter {
76
+ writeFile(path: string, content: string): Promise<void>;
77
+ readFile?(path: string): Promise<string | null>;
78
+ runCommand?(cmd: string, cwd: string): Promise<{ stdout: string; stderr: string; exit_code: number; success: boolean }>;
79
+ setWorkingDir(dir: string): void;
80
+ getWorkingDir(): string;
81
+ saveDeliberationOutput?(council: any, mode: 'full' | 'abbreviated'): Promise<string>;
82
+ }
83
+
84
+ // ============================================================================
85
+ // Input Template Rendering
86
+ // ============================================================================
87
+
88
+ /**
89
+ * Prepend a provenance header so downstream steps know what produced the content.
90
+ * Skip header for synthetic initial-input artifacts (stepId === '__initial__').
91
+ */
92
+ function formatArtifactForInput(artifact: StepArtifact): string {
93
+ if (artifact.stepId === '__initial__') {
94
+ return artifact.content;
95
+ }
96
+
97
+ const lines: string[] = [];
98
+ if (artifact.metadata?.stepName) {
99
+ const typeLabel = artifact.metadata.stepType ? ` (${artifact.metadata.stepType})` : '';
100
+ lines.push(`[Source: ${artifact.metadata.stepName}${typeLabel}]`);
101
+ }
102
+
103
+ const outputType = artifact.metadata?.outputType || 'string';
104
+ const outputPath = artifact.metadata?.outputPath;
105
+
106
+ if (outputType === 'json') {
107
+ lines.push(`[Output type: json]`);
108
+ } else if (outputType === 'directory' && outputPath) {
109
+ lines.push(`[Output type: directory]`);
110
+ lines.push(`[Output directory: ${outputPath}]`);
111
+ lines.push(`IMPORTANT: The previous step produced output in the directory above. Use your tools to list and read the files in that directory to understand the full context of what was produced.`);
112
+ } else if (outputType === 'file' && outputPath) {
113
+ lines.push(`[Output type: file]`);
114
+ lines.push(`[Output file: ${outputPath}]`);
115
+ lines.push(`IMPORTANT: The previous step produced output in the file above. Use your tools to read that file to understand the full context of what was produced.`);
116
+ } else if (outputPath) {
117
+ lines.push(`[Output file: ${outputPath}]`);
118
+ }
119
+
120
+ if (lines.length > 0) {
121
+ return lines.join('\n') + '\n\n' + artifact.content;
122
+ }
123
+ return artifact.content;
124
+ }
125
+
126
+ /**
127
+ * Parse artifact content as JSON and walk a dot-separated path.
128
+ * Returns the stringified value at the path, or '' if parsing fails or path not found.
129
+ */
130
+ function resolveJsonPath(content: string, dotPath: string): string {
131
+ try {
132
+ let obj = JSON.parse(content);
133
+ for (const key of dotPath.split('.')) {
134
+ if (obj == null || typeof obj !== 'object') return '';
135
+ obj = obj[key];
136
+ }
137
+ if (obj === undefined || obj === null) return '';
138
+ return typeof obj === 'string' ? obj : JSON.stringify(obj);
139
+ } catch {
140
+ return '';
141
+ }
142
+ }
143
+
144
+ function renderInputTemplate(
145
+ template: string,
146
+ previousArtifacts: StepArtifact[],
147
+ memoryCtx?: MemoryContext
148
+ ): string {
149
+ // "none" means no input from previous steps — step only sees its task
150
+ if (template === 'none') return '';
151
+
152
+ if (!template || template === '{{input}}') {
153
+ return previousArtifacts.map((a) => formatArtifactForInput(a)).join('\n\n---\n\n');
154
+ }
155
+
156
+ let result = template;
157
+
158
+ // Replace {{input}} with all artifacts joined (with provenance headers)
159
+ result = result.replace(
160
+ /\{\{input\}\}/g,
161
+ previousArtifacts.map((a) => formatArtifactForInput(a)).join('\n\n---\n\n')
162
+ );
163
+
164
+ // Replace {{input[N]}} with specific artifact (with provenance header)
165
+ result = result.replace(/\{\{input\[(\d+)\]\}\}/g, (_match, index) => {
166
+ const i = parseInt(index, 10);
167
+ return previousArtifacts[i] ? formatArtifactForInput(previousArtifacts[i]) : '';
168
+ });
169
+
170
+ // Replace {{file}} with all output file paths (newline-joined, non-null only)
171
+ result = result.replace(
172
+ /\{\{file\}\}/g,
173
+ previousArtifacts
174
+ .map((a) => a.metadata?.outputPath)
175
+ .filter(Boolean)
176
+ .join('\n')
177
+ );
178
+
179
+ // Replace {{file[N]}} with specific artifact's file path
180
+ result = result.replace(/\{\{file\[(\d+)\]\}\}/g, (_match, index) => {
181
+ const i = parseInt(index, 10);
182
+ return previousArtifacts[i]?.metadata?.outputPath || '';
183
+ });
184
+
185
+ // Replace {{input.fieldName}} with JSON field from last artifact (dot-path walk)
186
+ result = result.replace(/\{\{input\.([a-zA-Z0-9_.]+)\}\}/g, (_match, path) => {
187
+ const last = previousArtifacts[previousArtifacts.length - 1];
188
+ if (!last) return '';
189
+ return resolveJsonPath(last.content, path);
190
+ });
191
+
192
+ // Replace {{input[N].fieldName}} with JSON field from specific artifact
193
+ result = result.replace(/\{\{input\[(\d+)\]\.([a-zA-Z0-9_.]+)\}\}/g, (_match, index, path) => {
194
+ const i = parseInt(index, 10);
195
+ if (!previousArtifacts[i]) return '';
196
+ return resolveJsonPath(previousArtifacts[i].content, path);
197
+ });
198
+
199
+ // ---- Memory template variables ----
200
+ if (memoryCtx) {
201
+ // {{memory}} — all entries
202
+ result = result.replace(/\{\{memory\}\}/g, memoryCtx.all);
203
+
204
+ // {{memory.last_n(N)}} — last N entries (must be before {{memory.last.X}})
205
+ result = result.replace(/\{\{memory\.last_n\((\d+)\)\}\}/g, (_match, n) => {
206
+ return memoryCtx.lastN(parseInt(n, 10));
207
+ });
208
+
209
+ // {{memory.last.step_name}} — specific capture from last entry
210
+ result = result.replace(/\{\{memory\.last\.([a-zA-Z0-9_]+)\}\}/g, (_match, stepName) => {
211
+ return memoryCtx.lastCapture(stepName);
212
+ });
213
+
214
+ // {{memory.last}} — most recent entry
215
+ result = result.replace(/\{\{memory\.last\}\}/g, memoryCtx.last);
216
+
217
+ // {{memory.patterns}} — compressed pattern summaries
218
+ result = result.replace(/\{\{memory\.patterns\}\}/g, memoryCtx.patterns);
219
+ } else {
220
+ // No memory — resolve all memory templates to empty string
221
+ result = result.replace(/\{\{memory(?:\.[a-zA-Z0-9_()]+)*\}\}/g, '');
222
+ }
223
+
224
+ return result;
225
+ }
226
+
227
+ // ============================================================================
228
+ // Pipeline Executor
229
+ // ============================================================================
230
+
231
+ export class PipelineExecutor {
232
+ private callbacks: PipelineExecutorCallbacks;
233
+ private platform: PlatformAdapter;
234
+ private aborted = false;
235
+ private runningPipelineId: string | null = null;
236
+ /** Set by condition steps to skip the next stage */
237
+ private skipNextStage = false;
238
+ /** Set by condition steps to stop the pipeline (completes, not fails) */
239
+ private stopPipeline = false;
240
+ /** Memory context loaded at pipeline start (if maintainMemory is enabled) */
241
+ private memoryCtx: MemoryContext | undefined = undefined;
242
+ /** Current run directory for output isolation */
243
+ private currentRunDir: string | null = null;
244
+ /** Current run number */
245
+ private currentRunNumber = 0;
246
+ /** Pipeline start timestamp */
247
+ private runStartedAt: string | null = null;
248
+
249
+ constructor(callbacks: PipelineExecutorCallbacks, platform: PlatformAdapter) {
250
+ this.callbacks = callbacks;
251
+ this.platform = platform;
252
+ }
253
+
254
+ /**
255
+ * Run a pipeline from its current stage index.
256
+ * Supports resume — skips completed stages.
257
+ */
258
+ async run(pipelineId: string): Promise<void> {
259
+ const pipeline = pipelineStore.get(pipelineId);
260
+ if (!pipeline) throw new Error(`Pipeline not found: ${pipelineId}`);
261
+
262
+ if (pipeline.stages.length === 0) {
263
+ throw new Error('Pipeline has no stages');
264
+ }
265
+
266
+ this.aborted = false;
267
+ this.skipNextStage = false;
268
+ this.stopPipeline = false;
269
+ this.runningPipelineId = pipelineId;
270
+ this.memoryCtx = undefined;
271
+ this.currentRunDir = null;
272
+ this.currentRunNumber = 0;
273
+ this.runStartedAt = new Date().toISOString();
274
+ pipelineStore.setPipelineStatus(pipelineId, 'running');
275
+
276
+ const workingDir = pipeline.settings.workingDirectory;
277
+
278
+ // Load memory context if maintainMemory is enabled
279
+ if (workingDir && pipeline.settings.schedule?.maintainMemory) {
280
+ try {
281
+ this.memoryCtx = await buildMemoryContext(this.platform, workingDir, pipelineId);
282
+ console.log('[PipelineExecutor] Loaded memory context for pipeline');
283
+ } catch (err) {
284
+ console.warn('[PipelineExecutor] Failed to load memory context:', err);
285
+ }
286
+ }
287
+
288
+ // Set up run output directory
289
+ const outputConfig = pipeline.settings.outputConfig;
290
+ if (workingDir && outputConfig?.enabled !== false) {
291
+ try {
292
+ this.currentRunNumber = await getNextRunNumber(this.platform, workingDir, pipelineId);
293
+ const runDirName = buildRunDirName(pipeline.name, this.currentRunNumber, new Date());
294
+ const runsBase = getRunsBaseDir(workingDir);
295
+ this.currentRunDir = `${runsBase}/${runDirName}`;
296
+ console.log(`[PipelineExecutor] Run output dir: ${this.currentRunDir}`);
297
+ } catch (err) {
298
+ console.warn('[PipelineExecutor] Failed to set up run directory:', err);
299
+ }
300
+ }
301
+
302
+ try {
303
+ for (let i = pipeline.currentStageIndex; i < pipeline.stages.length; i++) {
304
+ if (this.aborted) {
305
+ return; // status already set by abort()
306
+ }
307
+
308
+ // Refresh pipeline state
309
+ const current = pipelineStore.get(pipelineId);
310
+ if (!current) throw new Error('Pipeline disappeared');
311
+
312
+ const stage = current.stages[i];
313
+
314
+ // Skip completed stages (for resume)
315
+ if (stage.steps.every((s) => s.status === 'completed' || s.status === 'skipped')) {
316
+ continue;
317
+ }
318
+
319
+ // Check if a condition step requested skipping this stage
320
+ if (this.skipNextStage) {
321
+ this.skipNextStage = false;
322
+ // Mark all steps in this stage as skipped
323
+ for (const step of stage.steps) {
324
+ if (step.status !== 'completed' && step.status !== 'skipped') {
325
+ pipelineStore.setStepStatus(pipelineId, step.id, 'skipped');
326
+ }
327
+ }
328
+ pipelineStore.advanceStage(pipelineId);
329
+ continue;
330
+ }
331
+
332
+ // Collect previous stage artifacts (or initial input for stage 0)
333
+ const previousArtifacts = this.collectPreviousArtifacts(current, i);
334
+
335
+ this.callbacks.onStageStart?.(i);
336
+
337
+ // Run all steps in this stage
338
+ await this.runStage(pipelineId, stage, previousArtifacts, current.settings.failurePolicy, current.settings);
339
+
340
+ // Check abort again after stage completes (don't advance if aborted)
341
+ if (this.aborted) {
342
+ return;
343
+ }
344
+
345
+ this.callbacks.onStageComplete?.(i);
346
+
347
+ // Advance stage index
348
+ pipelineStore.advanceStage(pipelineId);
349
+
350
+ // Check if a condition step requested stopping after this stage
351
+ if (this.stopPipeline) {
352
+ // Skip remaining stages
353
+ const updated = pipelineStore.get(pipelineId);
354
+ if (updated) {
355
+ for (let j = i + 1; j < updated.stages.length; j++) {
356
+ for (const step of updated.stages[j].steps) {
357
+ if (step.status === 'pending') {
358
+ pipelineStore.setStepStatus(pipelineId, step.id, 'skipped');
359
+ }
360
+ }
361
+ }
362
+ }
363
+ break;
364
+ }
365
+ }
366
+
367
+ if (!this.aborted) {
368
+ pipelineStore.setPipelineStatus(pipelineId, 'completed');
369
+
370
+ // Post-completion: capture memory and write run manifest
371
+ const completedPipeline = pipelineStore.get(pipelineId);
372
+ let memoryUpdated = false;
373
+
374
+ if (completedPipeline) {
375
+ // Capture memory if enabled
376
+ if (workingDir && completedPipeline.settings.schedule?.maintainMemory) {
377
+ try {
378
+ await this.captureMemory(completedPipeline, workingDir);
379
+ memoryUpdated = true;
380
+ console.log('[PipelineExecutor] Memory entry captured');
381
+ } catch (err) {
382
+ console.warn('[PipelineExecutor] Failed to capture memory:', err);
383
+ }
384
+ }
385
+
386
+ // Write run manifest
387
+ if (this.currentRunDir) {
388
+ try {
389
+ await this.writeManifest(completedPipeline, 'completed', memoryUpdated);
390
+ } catch (err) {
391
+ console.warn('[PipelineExecutor] Failed to write run manifest:', err);
392
+ }
393
+ }
394
+
395
+ // Prune old runs
396
+ const maxRetained = completedPipeline.settings.outputConfig?.maxRetainedRuns;
397
+ if (workingDir && maxRetained && maxRetained > 0) {
398
+ try {
399
+ const runsBase = getRunsBaseDir(workingDir);
400
+ await pruneOldRuns(this.platform, runsBase, completedPipeline.name, maxRetained);
401
+ } catch (err) {
402
+ console.warn('[PipelineExecutor] Failed to prune old runs:', err);
403
+ }
404
+ }
405
+ }
406
+ }
407
+ } catch (error) {
408
+ if (this.aborted) return; // don't overwrite 'paused' status on abort
409
+ const message = error instanceof Error ? error.message : String(error);
410
+ console.error('[PipelineExecutor] Pipeline failed:', message);
411
+ pipelineStore.setPipelineStatus(pipelineId, 'failed');
412
+
413
+ // Write failed manifest if we have a run directory
414
+ if (this.currentRunDir) {
415
+ const failedPipeline = pipelineStore.get(pipelineId);
416
+ if (failedPipeline) {
417
+ try {
418
+ await this.writeManifest(failedPipeline, 'failed', false);
419
+ } catch { /* best effort */ }
420
+ }
421
+ }
422
+
423
+ throw error;
424
+ } finally {
425
+ this.runningPipelineId = null;
426
+ this.memoryCtx = undefined;
427
+ this.currentRunDir = null;
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Abort a running pipeline. Sets status to 'paused' immediately and marks
433
+ * any currently-running steps as failed so the UI updates right away.
434
+ */
435
+ abort(): void {
436
+ this.aborted = true;
437
+ if (this.runningPipelineId) {
438
+ const pipeline = pipelineStore.get(this.runningPipelineId);
439
+ if (pipeline) {
440
+ // Mark any running steps as failed
441
+ for (const stage of pipeline.stages) {
442
+ for (const step of stage.steps) {
443
+ if (step.status === 'running') {
444
+ pipelineStore.setStepStatus(this.runningPipelineId, step.id, 'failed', 'Aborted by user');
445
+ }
446
+ }
447
+ }
448
+ pipelineStore.setPipelineStatus(this.runningPipelineId, 'failed');
449
+ }
450
+ }
451
+ }
452
+
453
+ // --------------------------------------------------------------------------
454
+ // Stage Execution
455
+ // --------------------------------------------------------------------------
456
+
457
+ private collectPreviousArtifacts(
458
+ pipeline: Pipeline,
459
+ currentStageIndex: number
460
+ ): StepArtifact[] {
461
+ if (currentStageIndex === 0) {
462
+ // For stage 0, create a synthetic artifact from initialInput
463
+ if (!pipeline.initialInput) return [];
464
+ return [
465
+ {
466
+ stepId: '__initial__',
467
+ content: pipeline.initialInput,
468
+ artifactType: 'output',
469
+ createdAt: new Date().toISOString(),
470
+ },
471
+ ];
472
+ }
473
+
474
+ const previousStage = pipeline.stages[currentStageIndex - 1];
475
+ return previousStage.steps
476
+ .filter((s) => s.artifact)
477
+ .map((s) => s.artifact!);
478
+ }
479
+
480
+ private async runStage(
481
+ pipelineId: string,
482
+ stage: PipelineStage,
483
+ previousArtifacts: StepArtifact[],
484
+ failurePolicy: 'stop' | 'skip_step',
485
+ pipelineSettings: Pipeline['settings']
486
+ ): Promise<void> {
487
+ const mode = stage.executionMode || 'sequential';
488
+
489
+ if (mode === 'parallel') {
490
+ // Run all steps concurrently
491
+ const results = await Promise.allSettled(
492
+ stage.steps.map((step) =>
493
+ this.runStep(pipelineId, step, previousArtifacts, pipelineSettings)
494
+ )
495
+ );
496
+
497
+ for (let i = 0; i < results.length; i++) {
498
+ const result = results[i];
499
+ if (result.status === 'rejected') {
500
+ const error = result.reason instanceof Error
501
+ ? result.reason.message
502
+ : String(result.reason);
503
+
504
+ if (failurePolicy === 'stop') {
505
+ throw new Error(`Step "${stage.steps[i].name}" failed: ${error}`);
506
+ }
507
+ }
508
+ }
509
+ } else {
510
+ // Run steps one at a time, in order.
511
+ // Accumulate artifacts so later steps can reference earlier steps' outputs.
512
+ const accumulatedArtifacts = [...previousArtifacts];
513
+ for (const step of stage.steps) {
514
+ if (this.aborted || this.stopPipeline) return;
515
+
516
+ try {
517
+ await this.runStep(pipelineId, step, accumulatedArtifacts, pipelineSettings);
518
+ // Add completed step's artifact for subsequent steps
519
+ const updated = pipelineStore.get(pipelineId);
520
+ const updatedStep = updated?.stages.flatMap(s => s.steps).find(s => s.id === step.id);
521
+ if (updatedStep?.artifact) {
522
+ accumulatedArtifacts.push(updatedStep.artifact);
523
+ }
524
+ } catch (error) {
525
+ if (failurePolicy === 'stop') {
526
+ throw error;
527
+ }
528
+ // skip_step: already marked as failed in runStep
529
+ }
530
+ }
531
+ }
532
+ }
533
+
534
+ // --------------------------------------------------------------------------
535
+ // Step Dispatch
536
+ // --------------------------------------------------------------------------
537
+
538
+ private async runStep(
539
+ pipelineId: string,
540
+ step: PipelineStep,
541
+ previousArtifacts: StepArtifact[],
542
+ pipelineSettings: Pipeline['settings']
543
+ ): Promise<void> {
544
+ // Skip already completed steps (for resume)
545
+ if (step.status === 'completed' || step.status === 'skipped') return;
546
+
547
+ pipelineStore.setStepStatus(pipelineId, step.id, 'running');
548
+ this.callbacks.onStepStart?.(step.id);
549
+
550
+ // Per-step context — avoids mutating shared this.callbacks (race-safe for parallel steps)
551
+ const stepCtx = { councilId: null as string | null };
552
+
553
+ try {
554
+ let artifact: StepArtifact;
555
+
556
+ if (step.config.type === 'gate') {
557
+ artifact = await this.runGateStep(pipelineId, step);
558
+ } else if (step.config.type === 'script') {
559
+ artifact = await this.runScriptStep(pipelineId, step, previousArtifacts);
560
+ } else if (step.config.type === 'condition') {
561
+ artifact = await this.runConditionStep(pipelineId, step, previousArtifacts);
562
+ } else {
563
+ // Convert LLM steps (decisioning/execution) to lightweight council configs
564
+ const councilStep = this.normalizeToCouncilStep(step);
565
+ artifact = await this.runCouncilStep(pipelineId, councilStep, previousArtifacts, pipelineSettings, stepCtx);
566
+ }
567
+
568
+ pipelineStore.setStepArtifact(pipelineId, step.id, artifact);
569
+ pipelineStore.setStepStatus(pipelineId, step.id, 'completed');
570
+
571
+ // Write step output to isolated run directory
572
+ if (this.currentRunDir) {
573
+ try {
574
+ const loc = this.findStepLocation(pipelineId, step.id);
575
+ if (loc) {
576
+ const stepDir = buildStepOutputDir(
577
+ this.currentRunDir, loc.stageIndex, loc.stageName, loc.stepIndex, step.name
578
+ );
579
+ const meta: StepMeta = {
580
+ stepId: step.id,
581
+ stepName: step.name,
582
+ stepType: step.config.type,
583
+ stageIndex: loc.stageIndex,
584
+ stepIndex: loc.stepIndex,
585
+ startedAt: step.startedAt,
586
+ completedAt: step.completedAt,
587
+ status: 'completed',
588
+ outputType: (step.config as any).outputType || 'string',
589
+ councilId: artifact.metadata?.councilId,
590
+ model: artifact.metadata?.model,
591
+ tokensUsed: artifact.metadata?.tokensUsed,
592
+ };
593
+ const outputPath = await writeStepOutput(this.platform, stepDir, artifact, meta);
594
+ console.log(`[PipelineExecutor] Step output: ${outputPath}`);
595
+ // Update artifact metadata to point to the isolated output file
596
+ artifact.metadata = { ...artifact.metadata, outputPath };
597
+ pipelineStore.setStepArtifact(pipelineId, step.id, artifact);
598
+ }
599
+ } catch (err) {
600
+ console.warn('[PipelineExecutor] Failed to write step output:', err);
601
+ }
602
+ }
603
+
604
+ this.callbacks.onStepComplete?.(step.id, artifact);
605
+ } catch (error) {
606
+ const message = error instanceof Error ? error.message : String(error);
607
+
608
+ // For steps that created a council before failing, write a partial
609
+ // artifact so the UI can still link to the deliberation ledger
610
+ if (stepCtx.councilId) {
611
+ pipelineStore.setStepArtifact(pipelineId, step.id, {
612
+ stepId: step.id,
613
+ content: `Step failed: ${message}`,
614
+ artifactType: 'output',
615
+ metadata: { councilId: stepCtx.councilId, stepName: step.name, stepType: step.config.type },
616
+ createdAt: new Date().toISOString(),
617
+ });
618
+ }
619
+
620
+ pipelineStore.setStepStatus(pipelineId, step.id, 'failed', message);
621
+ this.callbacks.onStepError?.(step.id, message);
622
+ throw error;
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Ensure a step has a CouncilStepConfig.
628
+ * Steps with councilSetup (new format) pass through unchanged.
629
+ * Legacy LlmStepConfig (flat model/provider/systemPrompt) is migrated.
630
+ */
631
+ private normalizeToCouncilStep(step: PipelineStep): PipelineStep {
632
+ const config = step.config;
633
+
634
+ // Already a CouncilStepConfig — has councilSetup
635
+ if ('councilSetup' in config) {
636
+ return step;
637
+ }
638
+
639
+ // Legacy LlmStepConfig — migrate to CouncilStepConfig
640
+ const migrated = migrateLlmConfig(config as LlmStepConfig);
641
+ return { ...step, config: migrated };
642
+ }
643
+
644
+ // --------------------------------------------------------------------------
645
+ // Council Step
646
+ // --------------------------------------------------------------------------
647
+
648
+ private async runCouncilStep(
649
+ pipelineId: string,
650
+ step: PipelineStep,
651
+ previousArtifacts: StepArtifact[],
652
+ pipelineSettings: Pipeline['settings'],
653
+ stepCtx: { councilId: string | null }
654
+ ): Promise<StepArtifact> {
655
+ const config = step.config as CouncilStepConfig;
656
+
657
+ // Build the problem: task (instructions) + pipeline input (optional) + input (context from previous steps)
658
+ const inputContext = renderInputTemplate(config.inputTemplate, previousArtifacts, this.memoryCtx);
659
+ const pipelineInput = config.includePipelineInput
660
+ ? pipelineStore.get(pipelineId)?.initialInput || ''
661
+ : '';
662
+ const parts = [config.task, pipelineInput, inputContext].filter(Boolean);
663
+ const rawProblem = parts.join('\n\n---\n\n');
664
+
665
+ // Pre-flight: verify MCP tools referenced in step prompts are actually available
666
+ if (this.callbacks.getAvailableTools) {
667
+ const promptText = [
668
+ config.task || '',
669
+ ...config.councilSetup.personas.map(p => p.systemPrompt || ''),
670
+ ].join('\n');
671
+ verifyRequiredTools(this.callbacks.getAvailableTools(), promptText, step.name);
672
+ }
673
+
674
+ // Resolve effective working directory with inheritance (default: constrained)
675
+ const isConstrained = pipelineSettings.directoryConstrained !== false;
676
+ const effectiveDir = isConstrained
677
+ ? pipelineSettings.workingDirectory
678
+ : config.councilSetup.workingDirectory || pipelineSettings.workingDirectory;
679
+
680
+ // Create council via factory
681
+ const council = createCouncilFromSetup({
682
+ ...config.councilSetup,
683
+ task: config.task,
684
+ topic: rawProblem.slice(0, 200),
685
+ workingDirectory: effectiveDir,
686
+ directoryConstrained: isConstrained,
687
+ saveDeliberation: true,
688
+ saveDeliberationMode: 'full',
689
+ stepType: config.type,
690
+ pipelinePrefix: '[Pipeline]',
691
+ pipelineId: pipelineId,
692
+ });
693
+
694
+ stepCtx.councilId = council.id;
695
+ this.callbacks.onCouncilCreated?.(step.id, council.id);
696
+
697
+ // Branch: coding steps use CodingOrchestrator, planning uses DeliberationOrchestrator
698
+ const orchestratorCallbacks = {
699
+ invokeAgent: this.callbacks.invokeAgent,
700
+ onPhaseChange: (from: any, to: any) =>
701
+ console.log(`[Pipeline:Council] Phase: ${from} -> ${to}`),
702
+ onError: (err: Error, ctx: string) =>
703
+ console.error(`[Pipeline:Council] Error in ${ctx}:`, err),
704
+ onAgentThinkingStart: this.callbacks.onAgentThinkingStart,
705
+ onAgentThinkingEnd: this.callbacks.onAgentThinkingEnd,
706
+ };
707
+
708
+ if (config.type === 'coding') {
709
+ const codingOrchestrator = new CodingOrchestrator({
710
+ ...orchestratorCallbacks,
711
+ runCommand: this.platform.runCommand,
712
+ readFile: this.platform.readFile,
713
+ });
714
+ await codingOrchestrator.runCodingWorkflow(council, rawProblem);
715
+ } else {
716
+ const deliberator = new DeliberationOrchestrator(orchestratorCallbacks);
717
+ await deliberator.runFullDeliberation(council, rawProblem);
718
+ }
719
+
720
+ // Generate summary and save to disk (normally done by React useEffect, but
721
+ // pipeline executor doesn't render DeliberationView)
722
+ let workerOutputPath: string | undefined;
723
+ const completedCouncil = councilStore.get(council.id);
724
+ if (completedCouncil) {
725
+ const summary = buildAbbreviatedSummary(completedCouncil);
726
+ councilStore.updateDeliberationState(council.id, { completionSummary: summary });
727
+
728
+ // Save deliberation output to working directory
729
+ if (effectiveDir) {
730
+ try {
731
+ if (this.platform.saveDeliberationOutput) {
732
+ const outputDir = await this.platform.saveDeliberationOutput(completedCouncil, 'full');
733
+ console.log(`[Pipeline:Council] Saved deliberation output to: ${outputDir}`);
734
+ }
735
+ } catch (err) {
736
+ console.error('[Pipeline:Council] Failed to save deliberation output:', err);
737
+ }
738
+
739
+ // Save worker output to run directory root (if output isolation active)
740
+ // Falls back to working directory if no run directory
741
+ const workerPersona = config.councilSetup.personas.find((p) => p.role === 'worker');
742
+ if (workerPersona && workerPersona.saveOutput !== false) {
743
+ const workerOutput = getLatestOutput(council.id);
744
+ if (workerOutput?.content) {
745
+ try {
746
+ const safeName = config.councilSetup.name
747
+ .toLowerCase().replace(/[^a-z0-9_-]/g, '_').replace(/_+/g, '_').slice(0, 50);
748
+ const suffix = config.type === 'coding' ? '_code.md' : config.type === 'review' ? '_review.md' : config.type === 'enrich' ? '_enrichment.md' : config.type === 'code_planning' ? '_plan.md' : '_output.md';
749
+ const outputBase = this.currentRunDir || effectiveDir.replace(/\/$/, '');
750
+ workerOutputPath = `${outputBase}/${safeName}${suffix}`;
751
+ await this.platform.writeFile(workerOutputPath, workerOutput.content);
752
+ console.log(`[Pipeline:Council] Saved worker output to: ${workerOutputPath}`);
753
+ } catch (err) {
754
+ console.error('[Pipeline:Council] Failed to save worker output:', err);
755
+ }
756
+ }
757
+ }
758
+ }
759
+ }
760
+
761
+ // Extract artifact — decisioning steps use the decision, all others use worker output
762
+ const updatedCouncil = councilStore.get(council.id);
763
+ let content = '';
764
+ let artifactType: StepArtifact['artifactType'] = 'output';
765
+ const metadata: StepArtifact['metadata'] = {
766
+ councilId: council.id,
767
+ outputPath: workerOutputPath,
768
+ outputType: config.outputType || 'string',
769
+ stepName: step.name,
770
+ stepType: config.type,
771
+ };
772
+
773
+ if (config.type === 'analysis') {
774
+ const decision = getDecision(council.id);
775
+ if (decision?.content) {
776
+ content = decision.content;
777
+ artifactType = 'decision';
778
+ metadata.decisionId = decision.id;
779
+ } else {
780
+ // Lightweight analysis goes through runDirectExecution → worker output
781
+ const output = getLatestOutput(council.id);
782
+ content = output?.content || 'No decision or output was produced.';
783
+ artifactType = 'output';
784
+ metadata.outputId = output?.id;
785
+ }
786
+ } else {
787
+ const output = getLatestOutput(council.id);
788
+ content = output?.content || 'No output was produced.';
789
+ artifactType = 'output';
790
+ metadata.outputId = output?.id;
791
+ }
792
+
793
+ // Strip the localStorage copy of this council's metadata to keep
794
+ // the mcp-councils key small. The authoritative council data
795
+ // (ledger, context, decision, etc.) remains in the in-memory
796
+ // CouncilDataStore, so it stays accessible for the rest of this session.
797
+ try {
798
+ stripCompletedCouncil(council.id);
799
+ } catch (err) {
800
+ console.warn('[Pipeline] Non-fatal: failed to strip council localStorage copy:', err);
801
+ }
802
+
803
+ return {
804
+ stepId: step.id,
805
+ content,
806
+ artifactType,
807
+ metadata,
808
+ createdAt: new Date().toISOString(),
809
+ };
810
+ }
811
+
812
+ // --------------------------------------------------------------------------
813
+ // Script Step
814
+ // --------------------------------------------------------------------------
815
+
816
+ private async runScriptStep(
817
+ pipelineId: string,
818
+ step: PipelineStep,
819
+ previousArtifacts: StepArtifact[]
820
+ ): Promise<StepArtifact> {
821
+ const config = step.config as ScriptStepConfig;
822
+
823
+ if (!this.platform.runCommand) {
824
+ throw new Error('Script steps require a platform with runCommand support');
825
+ }
826
+
827
+ if (!config.command.trim()) {
828
+ throw new Error('Script step has no command configured');
829
+ }
830
+
831
+ // Render input from previous steps and export as $KONDI_INPUT env var.
832
+ // The input is shell-escaped and passed via env var to avoid injection.
833
+ const stepInput = renderInputTemplate(config.inputTemplate, previousArtifacts, this.memoryCtx);
834
+ const pipelineInput = config.includePipelineInput
835
+ ? pipelineStore.get(pipelineId)?.initialInput || ''
836
+ : '';
837
+ const inputContext = [pipelineInput, stepInput].filter(Boolean).join('\n\n---\n\n');
838
+ const escaped = inputContext.replace(/'/g, "'\\''");
839
+ const command = `export KONDI_INPUT='${escaped}'\n${config.command}`;
840
+
841
+ const cwd = this.platform.getWorkingDir();
842
+ const result = await this.platform.runCommand(command, cwd);
843
+
844
+ if (!result.success) {
845
+ const errorDetail = result.stderr || result.stdout || `exit code ${result.exit_code}`;
846
+ throw new Error(`Script failed: ${errorDetail}`);
847
+ }
848
+
849
+ return {
850
+ stepId: step.id,
851
+ content: result.stdout,
852
+ artifactType: 'output',
853
+ metadata: {
854
+ stepName: step.name,
855
+ stepType: 'script',
856
+ outputType: config.outputType || 'string',
857
+ },
858
+ createdAt: new Date().toISOString(),
859
+ };
860
+ }
861
+
862
+ // --------------------------------------------------------------------------
863
+ // Condition Step
864
+ // --------------------------------------------------------------------------
865
+
866
+ private async runConditionStep(
867
+ pipelineId: string,
868
+ step: PipelineStep,
869
+ previousArtifacts: StepArtifact[]
870
+ ): Promise<StepArtifact> {
871
+ const config = step.config as ConditionStepConfig;
872
+
873
+ if (!config.expression) {
874
+ throw new Error('Condition step has no expression configured');
875
+ }
876
+
877
+ const stepInput = renderInputTemplate(config.inputTemplate, previousArtifacts, this.memoryCtx);
878
+ const pipelineInput = config.includePipelineInput
879
+ ? pipelineStore.get(pipelineId)?.initialInput || ''
880
+ : '';
881
+ const inputContext = [pipelineInput, stepInput].filter(Boolean).join('\n\n---\n\n');
882
+
883
+ // Evaluate the condition
884
+ let matches = false;
885
+ switch (config.mode) {
886
+ case 'contains':
887
+ matches = inputContext.includes(config.expression);
888
+ break;
889
+ case 'equals':
890
+ matches = inputContext.trim() === config.expression.trim();
891
+ break;
892
+ case 'regex':
893
+ try {
894
+ matches = new RegExp(config.expression).test(inputContext);
895
+ } catch {
896
+ throw new Error(`Invalid regex in condition: ${config.expression}`);
897
+ }
898
+ break;
899
+ }
900
+
901
+ const action = matches ? config.trueAction : config.falseAction;
902
+ const resultLabel = matches ? 'TRUE' : 'FALSE';
903
+
904
+ // Apply the action
905
+ if (action === 'skip_next_stage') {
906
+ this.skipNextStage = true;
907
+ } else if (action === 'stop') {
908
+ this.stopPipeline = true;
909
+ }
910
+
911
+ return {
912
+ stepId: step.id,
913
+ content: `Condition evaluated: ${resultLabel} (mode: ${config.mode}, expression: "${config.expression}"). Action: ${action}.`,
914
+ artifactType: 'output',
915
+ metadata: { stepName: step.name, stepType: 'condition' },
916
+ createdAt: new Date().toISOString(),
917
+ };
918
+ }
919
+
920
+ // --------------------------------------------------------------------------
921
+ // Gate Step
922
+ // --------------------------------------------------------------------------
923
+
924
+ private async runGateStep(
925
+ pipelineId: string,
926
+ step: PipelineStep
927
+ ): Promise<StepArtifact> {
928
+ const config = step.config as GateStepConfig;
929
+
930
+ pipelineStore.setStepStatus(pipelineId, step.id, 'waiting_approval');
931
+
932
+ if (!this.callbacks.onGateWaiting) {
933
+ throw new Error('No gate approval handler configured');
934
+ }
935
+
936
+ const approved = await this.callbacks.onGateWaiting(step.id, config.approvalPrompt);
937
+
938
+ if (!approved) {
939
+ throw new Error('Gate step rejected by user');
940
+ }
941
+
942
+ return {
943
+ stepId: step.id,
944
+ content: 'Approved',
945
+ artifactType: 'approval',
946
+ createdAt: new Date().toISOString(),
947
+ };
948
+ }
949
+
950
+ // --------------------------------------------------------------------------
951
+ // Memory Capture
952
+ // --------------------------------------------------------------------------
953
+
954
+ private async captureMemory(pipeline: Pipeline, workingDir: string): Promise<void> {
955
+ const schedule = pipeline.settings.schedule;
956
+ if (!schedule?.maintainMemory) return;
957
+
958
+ const allSteps = pipeline.stages.flatMap((s) => s.steps);
959
+ const captureIds = schedule.captureStepIds;
960
+
961
+ // Determine which steps to capture
962
+ let stepsToCapture: PipelineStep[];
963
+ if (captureIds && captureIds.length > 0) {
964
+ stepsToCapture = captureIds
965
+ .map((id) => allSteps.find((s) => s.id === id))
966
+ .filter((s): s is PipelineStep => s !== undefined && s.artifact !== undefined);
967
+ } else {
968
+ // Default: capture last completed step
969
+ const lastCompleted = [...allSteps].reverse().find((s) => s.status === 'completed' && s.artifact);
970
+ stepsToCapture = lastCompleted ? [lastCompleted] : [];
971
+ }
972
+
973
+ if (stepsToCapture.length === 0) return;
974
+
975
+ // Build captures map keyed by sanitized step name
976
+ const captures: Record<string, string> = {};
977
+ for (const step of stepsToCapture) {
978
+ const key = sanitizeFolderName(step.name);
979
+ captures[key] = step.artifact!.content;
980
+ }
981
+
982
+ const runNumber = this.currentRunNumber || await getNextRunNumber(this.platform, workingDir, pipeline.id);
983
+
984
+ await appendEntry(this.platform, workingDir, pipeline.id, {
985
+ runNumber,
986
+ runDate: new Date().toISOString(),
987
+ captures,
988
+ });
989
+ }
990
+
991
+ // --------------------------------------------------------------------------
992
+ // Run Manifest
993
+ // --------------------------------------------------------------------------
994
+
995
+ private async writeManifest(
996
+ pipeline: Pipeline,
997
+ status: 'completed' | 'failed',
998
+ memoryUpdated: boolean
999
+ ): Promise<void> {
1000
+ if (!this.currentRunDir) return;
1001
+
1002
+ const allSteps = pipeline.stages.flatMap((s) => s.steps);
1003
+ const totalTokens = allSteps.reduce(
1004
+ (sum, s) => sum + (s.artifact?.metadata?.tokensUsed || 0), 0
1005
+ );
1006
+ const startMs = this.runStartedAt ? new Date(this.runStartedAt).getTime() : Date.now();
1007
+
1008
+ const manifest: RunManifest = {
1009
+ runNumber: this.currentRunNumber,
1010
+ pipelineId: pipeline.id,
1011
+ pipelineName: pipeline.name,
1012
+ startedAt: this.runStartedAt || new Date().toISOString(),
1013
+ completedAt: new Date().toISOString(),
1014
+ status,
1015
+ initialInput: pipeline.initialInput,
1016
+ stageCount: pipeline.stages.length,
1017
+ stepCount: allSteps.length,
1018
+ totalTokens,
1019
+ totalDurationMs: Date.now() - startMs,
1020
+ memoryUpdated,
1021
+ };
1022
+
1023
+ await writeRunManifest(this.platform, this.currentRunDir, manifest);
1024
+ }
1025
+
1026
+ // --------------------------------------------------------------------------
1027
+ // Step Location Helper
1028
+ // --------------------------------------------------------------------------
1029
+
1030
+ private findStepLocation(pipelineId: string, stepId: string): {
1031
+ stageIndex: number;
1032
+ stageName: string;
1033
+ stepIndex: number;
1034
+ } | null {
1035
+ const pipeline = pipelineStore.get(pipelineId);
1036
+ if (!pipeline) return null;
1037
+
1038
+ for (let si = 0; si < pipeline.stages.length; si++) {
1039
+ const stage = pipeline.stages[si];
1040
+ for (let sti = 0; sti < stage.steps.length; sti++) {
1041
+ if (stage.steps[sti].id === stepId) {
1042
+ return { stageIndex: si, stageName: stage.name, stepIndex: sti };
1043
+ }
1044
+ }
1045
+ }
1046
+ return null;
1047
+ }
1048
+ }