sinapse-ai 1.19.0 → 1.19.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.
@@ -231,6 +231,11 @@ class BuildOrchestrator extends EventEmitter {
231
231
  // quality gate can inspect its honesty (degraded/stub/subtasks). Without this
232
232
  // the epic4_to_epic6 gate has nothing real to bite on.
233
233
  plan: ctx.plan,
234
+ // epic: empty-build-honesty — surface the REAL files the build wrote/modified
235
+ // (collected from the build loop's per-subtask results). Without this the
236
+ // downstream `implementation_exists` gate cannot tell a real build apart from
237
+ // an empty "success" that touched zero code files.
238
+ filesModified: this._collectModifiedFiles(ctx),
234
239
  };
235
240
  } catch (error) {
236
241
  ctx.errors.push(error);
@@ -263,6 +268,32 @@ class BuildOrchestrator extends EventEmitter {
263
268
  }
264
269
  }
265
270
 
271
+ /**
272
+ * Collect the real files the build wrote/modified from the build-loop result.
273
+ *
274
+ * epic: empty-build-honesty — the per-subtask results carry `filesModified`
275
+ * (extracted from the agent output). This flattens + de-duplicates them into a
276
+ * single honest list. Returns [] when nothing was touched (an empty build), which
277
+ * is precisely the signal the `implementation_exists` gate needs to block.
278
+ *
279
+ * @param {Object} ctx - Build context ({ result: { results: [...] } })
280
+ * @returns {string[]} De-duplicated list of modified files
281
+ * @private
282
+ */
283
+ _collectModifiedFiles(ctx) {
284
+ const results = ctx && ctx.result && Array.isArray(ctx.result.results) ? ctx.result.results : [];
285
+ const files = [];
286
+ for (const r of results) {
287
+ const list = r && Array.isArray(r.filesModified) ? r.filesModified : [];
288
+ for (const f of list) {
289
+ if (typeof f === 'string' && f.trim() !== '' && !files.includes(f)) {
290
+ files.push(f);
291
+ }
292
+ }
293
+ }
294
+ return files;
295
+ }
296
+
266
297
  /**
267
298
  * Run a phase with event emission and error handling
268
299
  */
@@ -10,8 +10,7 @@
10
10
  *
11
11
  * Integrates all Epic 11 modules:
12
12
  * - ExecutorAssignment (11.1) — agent selection
13
- * - TerminalSpawner (11.2) — agent spawning
14
- * - WorkflowExecutor (11.3) — development cycle
13
+ * - WorkflowExecutor (11.3) — development cycle (honest manual hand-off)
15
14
  * - SurfaceChecker (11.4) — human decision criteria
16
15
  * - SessionState (11.5) — session persistence
17
16
  *
@@ -1252,8 +1251,7 @@ class BobOrchestrator {
1252
1251
  *
1253
1252
  * Delegates to Epic 11 modules:
1254
1253
  * - ExecutorAssignment for agent selection (AC8)
1255
- * - TerminalSpawner for agent spawning (AC9)
1256
- * - WorkflowExecutor for development cycle (AC10)
1254
+ * - WorkflowExecutor for development cycle, with honest manual hand-off (AC10)
1257
1255
  *
1258
1256
  * Story 12.5 AC5: Updates session state at each phase transition.
1259
1257
  *
@@ -13,6 +13,7 @@
13
13
 
14
14
  const fs = require('fs-extra');
15
15
  const path = require('path');
16
+ const yaml = require('js-yaml');
16
17
  const EpicExecutor = require('./epic-executor');
17
18
 
18
19
  /**
@@ -112,13 +113,23 @@ class Epic4Executor extends EpicExecutor {
112
113
  if (this._realExecutionAllowed()) {
113
114
  const buildResult = await this._executeViaBuildOrchestrator(storyId, context);
114
115
  if (buildResult && buildResult.success) {
116
+ // Honesty invariant (epic: empty-build-honesty): do NOT set
117
+ // `implementationPath = planPath`. A plan path ALWAYS exists and is NOT
118
+ // proof of implementation — the multi-story measurement caught the gate
119
+ // approving an empty build because of exactly this. Surface the REAL files
120
+ // the build touched so the epic4_to_epic6 gate can tell an implementation
121
+ // apart from an empty "success".
122
+ const codeChanges = Array.isArray(buildResult.filesModified)
123
+ ? buildResult.filesModified
124
+ : [];
115
125
  return this._completeExecution({
116
- implementationPath: planPath,
117
126
  planPath,
118
127
  // F5 (epic: orchestration-consolidation): propagate the real plan object
119
128
  // so the downstream epic4_to_epic6 gate can verify it is not degraded/stub.
120
129
  plan: buildResult.plan,
121
130
  build: buildResult,
131
+ codeChanges,
132
+ filesModified: codeChanges,
122
133
  reportPath: buildResult.reportPath,
123
134
  phases: buildResult.phases,
124
135
  });
@@ -143,7 +154,10 @@ class Epic4Executor extends EpicExecutor {
143
154
  return this._stubExecution(
144
155
  'Epic 4 stub mode — real build is delegated to BuildOrchestrator outside the test runner',
145
156
  {
146
- implementationPath: planPath,
157
+ // Honesty invariant (epic: empty-build-honesty): no `implementationPath`
158
+ // here — the plan path is not an implementation. The stub already reports
159
+ // success:false, and `codeChanges` carries whatever real files (if any)
160
+ // the subtasks touched.
147
161
  planPath,
148
162
  progress,
149
163
  subtaskResults,
@@ -215,44 +229,47 @@ class Epic4Executor extends EpicExecutor {
215
229
  * @private
216
230
  */
217
231
  async _createStubPlan(planPath, storyId, specPath) {
218
- const stubPlan = `# Implementation Plan: ${storyId}
219
- # Generated: ${new Date().toISOString()}
220
-
221
- metadata:
222
- storyId: "${storyId}"
223
- specPath: "${specPath || 'N/A'}"
224
- status: draft
225
- createdAt: "${new Date().toISOString()}"
226
-
227
- phases:
228
- - phase: 1
229
- name: Setup
230
- subtasks:
231
- - id: "1.1"
232
- name: Initialize project structure
233
- status: pending
234
-
235
- - phase: 2
236
- name: Implementation
237
- subtasks:
238
- - id: "2.1"
239
- name: Implement core functionality
240
- status: pending
241
-
242
- - phase: 3
243
- name: Testing
244
- subtasks:
245
- - id: "3.1"
246
- name: Write tests
247
- status: pending
248
-
249
- - phase: 4
250
- name: Documentation
251
- subtasks:
252
- - id: "4.1"
253
- name: Update documentation
254
- status: pending
255
- `;
232
+ const now = new Date().toISOString();
233
+
234
+ // Build the plan as an object and serialize via yaml.dump. This is robust to
235
+ // any content — notably Windows absolute paths in `specPath`
236
+ // (e.g. "C:\Users\...\AppData\...") which, when hand-written into a
237
+ // double-quoted YAML scalar, contain invalid escape sequences (`\U`, `\A`)
238
+ // that make `yaml.load` throw and kill the whole build path on Windows.
239
+ // yaml.dump escapes/quotes correctly for any value (Story: FIX windows-path-yaml-plan).
240
+ const planObject = {
241
+ metadata: {
242
+ storyId,
243
+ specPath: specPath || 'N/A',
244
+ status: 'draft',
245
+ createdAt: now,
246
+ },
247
+ phases: [
248
+ {
249
+ phase: 1,
250
+ name: 'Setup',
251
+ subtasks: [{ id: '1.1', name: 'Initialize project structure', status: 'pending' }],
252
+ },
253
+ {
254
+ phase: 2,
255
+ name: 'Implementation',
256
+ subtasks: [{ id: '2.1', name: 'Implement core functionality', status: 'pending' }],
257
+ },
258
+ {
259
+ phase: 3,
260
+ name: 'Testing',
261
+ subtasks: [{ id: '3.1', name: 'Write tests', status: 'pending' }],
262
+ },
263
+ {
264
+ phase: 4,
265
+ name: 'Documentation',
266
+ subtasks: [{ id: '4.1', name: 'Update documentation', status: 'pending' }],
267
+ },
268
+ ],
269
+ };
270
+
271
+ const header = `# Implementation Plan: ${storyId}\n# Generated: ${now}\n\n`;
272
+ const stubPlan = header + yaml.dump(planObject, { lineWidth: -1, noRefs: true });
256
273
 
257
274
  await fs.ensureDir(path.dirname(planPath));
258
275
  await fs.writeFile(planPath, stubPlan);
@@ -202,6 +202,22 @@ class GateEvaluator {
202
202
  async _runGateChecks(fromEpic, toEpic, epicResult, gateConfig) {
203
203
  const checks = [];
204
204
 
205
+ // Universal honesty guard (master/gate leak fix, epic: orchestration-consolidation):
206
+ // a gate must NEVER approve a result that itself signals failure. The e2e checkpoint
207
+ // caught the gate approving (score 5.0) a QA report whose verdict was BLOCKED, because
208
+ // the existence checks (qa_report_exists / verdict_generated) never inspected the VALUE.
209
+ // This runs on EVERY gate, but ONLY adds a (failing, critical) check when a real failure
210
+ // signal is present — so it never inflates an honest zero-check gate (F5 invariant).
211
+ const failureSignal = this._detectFailureSignal(epicResult);
212
+ if (failureSignal) {
213
+ checks.push({
214
+ name: 'result_not_failed',
215
+ passed: false,
216
+ message: failureSignal,
217
+ severity: 'critical',
218
+ });
219
+ }
220
+
205
221
  // Get check list for this gate
206
222
  const checkNames = gateConfig.checks || this._getDefaultChecks(fromEpic, toEpic);
207
223
 
@@ -254,6 +270,99 @@ class GateEvaluator {
254
270
  return checks;
255
271
  }
256
272
 
273
+ /**
274
+ * Detect whether an epic result itself signals failure (master/gate leak fix).
275
+ *
276
+ * Returns a human-readable reason string when the result is NOT sound, or null when
277
+ * no failure signal is present. Used by `_runGateChecks` to add a critical, blocking
278
+ * check so a gate can never APPROVE a failed/blocked upstream result.
279
+ *
280
+ * @param {Object} epicResult - Result from the source epic
281
+ * @returns {string|null} Failure reason, or null if the result shows no failure signal
282
+ * @private
283
+ */
284
+ _detectFailureSignal(epicResult) {
285
+ if (!epicResult || typeof epicResult !== 'object') {
286
+ return null;
287
+ }
288
+ if (epicResult.success === false || epicResult.status === 'failed') {
289
+ return 'Epic result signals failure (success:false / status:failed)';
290
+ }
291
+ const norm = (v) => (typeof v === 'string' ? v.toLowerCase() : null);
292
+ const verdict = norm(epicResult.verdict);
293
+ if (verdict === 'blocked' || verdict === 'failed') {
294
+ return `Epic verdict is "${epicResult.verdict}"`;
295
+ }
296
+ const qaVerdict = epicResult.qaReport ? norm(epicResult.qaReport.verdict) : null;
297
+ if (qaVerdict === 'blocked' || qaVerdict === 'failed') {
298
+ return `QA report verdict is "${epicResult.qaReport.verdict}"`;
299
+ }
300
+ return null;
301
+ }
302
+
303
+ /**
304
+ * Collect the REAL code files an epic actually wrote/modified.
305
+ *
306
+ * Honesty invariant (epic: empty-build-honesty): a plan file is NOT an
307
+ * implementation. This gathers the concrete files touched from `codeChanges` /
308
+ * `filesModified` (and `build.filesModified` when the build result is nested),
309
+ * then EXCLUDES the plan artifact (`planPath` / `implementationPath`) so a build
310
+ * that only produced a plan yields an empty list — and the caller can block it.
311
+ *
312
+ * Entries may be strings or `{ path | file }` objects; blanks and duplicates are
313
+ * dropped. Returns a de-duplicated array of file paths (never the plan itself).
314
+ *
315
+ * @param {Object} epicResult - Result from the source epic
316
+ * @returns {string[]} Real implementation files (plan artifact excluded)
317
+ * @private
318
+ */
319
+ _collectImplementationFiles(epicResult) {
320
+ if (!epicResult || typeof epicResult !== 'object') {
321
+ return [];
322
+ }
323
+
324
+ const resolveSafe = (p) => {
325
+ try {
326
+ return path.resolve(p);
327
+ } catch {
328
+ return null;
329
+ }
330
+ };
331
+
332
+ // The plan artifact is explicitly NOT an implementation — exclude it.
333
+ const planPaths = new Set(
334
+ [epicResult.planPath, epicResult.implementationPath]
335
+ .filter((p) => typeof p === 'string' && p.trim() !== '')
336
+ .map(resolveSafe)
337
+ .filter(Boolean),
338
+ );
339
+
340
+ const raw = []
341
+ .concat(Array.isArray(epicResult.codeChanges) ? epicResult.codeChanges : [])
342
+ .concat(Array.isArray(epicResult.filesModified) ? epicResult.filesModified : [])
343
+ .concat(
344
+ epicResult.build && Array.isArray(epicResult.build.filesModified)
345
+ ? epicResult.build.filesModified
346
+ : [],
347
+ );
348
+
349
+ const files = [];
350
+ for (const entry of raw) {
351
+ const file = typeof entry === 'string' ? entry : entry && (entry.path || entry.file);
352
+ if (typeof file !== 'string' || file.trim() === '') {
353
+ continue;
354
+ }
355
+ const resolved = resolveSafe(file);
356
+ if (resolved && planPaths.has(resolved)) {
357
+ continue; // a plan path masquerading as a code change — not implementation
358
+ }
359
+ if (!files.includes(file)) {
360
+ files.push(file);
361
+ }
362
+ }
363
+ return files;
364
+ }
365
+
257
366
  /**
258
367
  * Run a single check
259
368
  * @private
@@ -334,11 +443,23 @@ class GateEvaluator {
334
443
  break;
335
444
  }
336
445
 
337
- case 'implementation_exists':
338
- result.passed = !!epicResult.implementationPath || epicResult.codeChanges?.length > 0;
339
- result.message = result.passed ? 'Implementation exists' : 'No implementation found';
446
+ case 'implementation_exists': {
447
+ // Honesty invariant (epic: empty-build-honesty): "implementation exists"
448
+ // MUST mean real code was written not merely that a PLAN file exists. The
449
+ // multi-story measurement (2026-06-30) caught this gate APPROVING (score 5.0)
450
+ // a build that wrote ZERO code files: the old check trusted
451
+ // `implementationPath`, but epic-4 set that to the PLAN path (which ALWAYS
452
+ // exists), so an empty build slipped through. Base the check on the real list
453
+ // of files touched by the build, excluding the plan artifact itself. Empty
454
+ // build → zero real files → critical fail → gate BLOCKS (empty ≠ success).
455
+ const files = this._collectImplementationFiles(epicResult);
456
+ result.passed = files.length > 0;
457
+ result.message = result.passed
458
+ ? `Implementation exists (${files.length} code file(s) changed)`
459
+ : 'No implementation found — build wrote zero code files (plan path is not implementation)';
340
460
  result.severity = 'critical';
341
461
  break;
462
+ }
342
463
 
343
464
  case 'no_critical_errors': {
344
465
  const criticalErrors = (epicResult.errors || []).filter((e) => e.severity === 'critical');
@@ -624,62 +624,30 @@ class GreenfieldHandler extends EventEmitter {
624
624
  // ═══════════════════════════════════════════════════════════════════════════════════
625
625
 
626
626
  /**
627
- * Spawns an agent via TerminalSpawner (AC7) with ExecutorAssignment (AC6)
627
+ * Resolves agent execution to an honest manual hand-off (AC6 selection only).
628
+ *
629
+ * The legacy visual-terminal lineage (TerminalSpawner → pm.sh) was a verified stub that
630
+ * never invoked claude, so spawning it fabricated success in headless-with-bash. It has
631
+ * been removed (STORY-F3C); execution is now handed off honestly to the user. Routing to
632
+ * the canonical `dispatcher → claude` lineage is intentionally out of scope here.
628
633
  *
629
634
  * @param {string} agent - Agent ID (e.g., '@devops')
630
635
  * @param {string} task - Task to execute
631
636
  * @param {Object} spawnContext - Spawn context
632
- * @returns {Promise<Object>} Spawn result
637
+ * @returns {Promise<Object>} Honest manual hand-off result
633
638
  * @private
634
639
  */
635
640
  async _spawnAgent(agent, task, spawnContext = {}) {
636
- this._log(`Spawning ${agent} for ${task}`);
641
+ this._log(`Handing off ${agent} for ${task} (manual execution)`);
637
642
 
638
643
  this.emit('agentSpawn', { agent, task, context: spawnContext });
639
644
 
640
- try {
641
- // Try TerminalSpawner first (AC7)
642
- const TerminalSpawner = require('./terminal-spawner');
643
-
644
- if (TerminalSpawner.isSpawnerAvailable()) {
645
- const agentId = agent.replace('@', '');
646
- const result = await TerminalSpawner.spawnAgent(agentId, task, {
647
- context: {
648
- instructions: spawnContext.instructions || `Execute ${task}`,
649
- creates: spawnContext.creates || [],
650
- requires: spawnContext.requires || [],
651
- metadata: spawnContext.previousResults || {},
652
- },
653
- timeout: 7200000, // 2 hours
654
- debug: this.options.debug,
655
- });
656
-
657
- if (result.pid) {
658
- this.emit('terminalSpawn', { agent, pid: result.pid, task });
659
- }
660
-
661
- return {
662
- success: result.success !== false,
663
- pid: result.pid,
664
- output: result.output,
665
- outputFile: result.outputFile,
666
- };
667
- }
668
-
669
- // Fallback: Return instructions for manual execution
670
- this._log(`TerminalSpawner not available, returning manual instructions for ${agent}`);
671
- return {
672
- success: true,
673
- manual: true,
674
- instructions: `Spawn ${agent} manually and execute: *${task}`,
675
- };
676
- } catch (error) {
677
- this._log(`Failed to spawn ${agent}: ${error.message}`, 'error');
678
- return {
679
- success: false,
680
- error: error.message,
681
- };
682
- }
645
+ // Honest hand-off: return instructions for manual execution (no fabricated success).
646
+ return {
647
+ success: true,
648
+ manual: true,
649
+ instructions: `Spawn ${agent} manually and execute: *${task}`,
650
+ };
683
651
  }
684
652
 
685
653
  // ═══════════════════════════════════════════════════════════════════════════════════
@@ -41,9 +41,6 @@ const cliCommands = require('./cli-commands');
41
41
  // Story 11.1: Executor Assignment (Projeto Bob)
42
42
  const ExecutorAssignment = require('./executor-assignment');
43
43
 
44
- // Story 11.2: Terminal Spawner (Projeto Bob)
45
- const TerminalSpawner = require('./terminal-spawner');
46
-
47
44
  // Story 11.3: Workflow Executor (Projeto Bob)
48
45
  const {
49
46
  WorkflowExecutor,
@@ -216,15 +213,6 @@ module.exports = {
216
213
  validateExecutorAssignment: ExecutorAssignment.validateExecutorAssignment,
217
214
  EXECUTOR_ASSIGNMENT_TABLE: ExecutorAssignment.EXECUTOR_ASSIGNMENT_TABLE,
218
215
 
219
- // Story 11.2: Terminal Spawner (Projeto Bob)
220
- TerminalSpawner,
221
- spawnAgent: TerminalSpawner.spawnAgent,
222
- createContextFile: TerminalSpawner.createContextFile,
223
- pollForOutput: TerminalSpawner.pollForOutput,
224
- isSpawnerAvailable: TerminalSpawner.isSpawnerAvailable,
225
- getPlatform: TerminalSpawner.getPlatform,
226
- cleanupOldFiles: TerminalSpawner.cleanupOldFiles,
227
-
228
216
  // Story 11.3: Workflow Executor (Projeto Bob)
229
217
  WorkflowExecutor,
230
218
  createWorkflowExecutor,
@@ -478,10 +478,15 @@ class MasterOrchestrator extends EventEmitter {
478
478
  } else {
479
479
  pipelineResult.epicsFailed.push(epicNum);
480
480
 
481
- // In strict mode or if epic is critical, stop pipeline
481
+ // Honesty invariant (master/gate leak fix): ANY failed epic means the
482
+ // pipeline did not fully succeed — not only critical/strict ones. A
483
+ // non-critical failure (e.g. QA/Epic 6) must still flip success:false so
484
+ // finalize() never reports an overall green build over a red epic.
485
+ pipelineResult.success = false;
486
+
487
+ // In strict mode or if epic is critical, also STOP the pipeline now.
482
488
  if (this.strictGates || this._isEpicCritical(epicNum)) {
483
489
  this._log(`Critical epic ${epicNum} failed, halting pipeline`);
484
- pipelineResult.success = false;
485
490
  break;
486
491
  }
487
492
  }
@@ -604,6 +609,45 @@ class MasterOrchestrator extends EventEmitter {
604
609
  orchestrator: this,
605
610
  });
606
611
 
612
+ // Honesty invariant (epic: orchestration-consolidation — master/gate leak fix):
613
+ // an executor may signal failure by RETURNING { success:false } / { status:'failed' }
614
+ // WITHOUT throwing. The old code fell straight through to "mark COMPLETED" + logged
615
+ // "completed successfully" + returned { success:true } for any non-stub result — so a
616
+ // failed epic leaked a green pipeline (the e2e "Epic 4 failed" → "Epic 4 completed
617
+ // successfully" → exit 0 symptom). The `catch` below only handles THROWN errors, so a
618
+ // returned failure must be detected explicitly here and treated as a real failure.
619
+ const isStubResult = result && result.status === 'stub';
620
+ const isFailedResult =
621
+ result && !isStubResult && (result.success === false || result.status === 'failed');
622
+
623
+ if (isFailedResult) {
624
+ this.executionState.epics[epicNum] = {
625
+ ...this.executionState.epics[epicNum],
626
+ status: EpicStatus.FAILED,
627
+ completedAt: new Date().toISOString(),
628
+ result,
629
+ errors: [
630
+ ...(this.executionState.epics[epicNum]?.errors || []),
631
+ {
632
+ message: result.error || `Epic ${epicNum} returned a non-success result`,
633
+ timestamp: new Date().toISOString(),
634
+ },
635
+ ],
636
+ };
637
+
638
+ await this._saveState();
639
+
640
+ this.emit('epicComplete', { epicNum, result, gateResult: null });
641
+ this.onEpicComplete(epicNum, result);
642
+
643
+ this._log(`Epic ${epicNum} failed (executor returned non-success result)`, {
644
+ level: 'error',
645
+ icon: '❌',
646
+ });
647
+
648
+ return { success: false, status: 'failed', epicNum, result };
649
+ }
650
+
607
651
  // Mark as completed
608
652
  this.executionState.epics[epicNum] = {
609
653
  ...this.executionState.epics[epicNum],
@@ -614,8 +658,8 @@ class MasterOrchestrator extends EventEmitter {
614
658
 
615
659
  // Evaluate quality gate (Story 0.6) - only in full pipeline mode
616
660
  // Skip gate evaluation if result is from stub executor
661
+ // (isStubResult already computed above with the failure-detection guard).
617
662
  let gateResult = null;
618
- const isStubResult = result && result.status === 'stub';
619
663
  if (this._inFullPipeline && result && result.success !== false && !isStubResult) {
620
664
  gateResult = await this._evaluateGate(epicNum, result);
621
665
 
@@ -1486,13 +1530,25 @@ class MasterOrchestrator extends EventEmitter {
1486
1530
  const seconds = Math.floor((duration % 60000) / 1000);
1487
1531
 
1488
1532
  const hasStubs = pipelineResult.hasStubs || (pipelineResult.epicsStubbed || []).length > 0;
1533
+ // Honesty invariant (master/gate leak fix): a pipeline with ANY failed epic, or a
1534
+ // final state of BLOCKED/FAILED, can never report success:true — even if a stale
1535
+ // optimistic flag survived. `blocked` is surfaced so the CLI shows BLOCKED (exit 2)
1536
+ // instead of "ORCHESTRATION COMPLETE" / exit 0.
1537
+ const hasFailures = (pipelineResult.epicsFailed || []).length > 0;
1538
+ const blocked = this._state === OrchestratorState.BLOCKED;
1489
1539
  return {
1490
1540
  workflowId: this.executionState.workflowId,
1491
1541
  storyId: this.storyId,
1492
1542
  status: this._state,
1543
+ blocked,
1493
1544
  // Honesty invariant (epic: orchestration-consolidation, F0a): a pipeline that ran
1494
1545
  // any epic in STUB mode did NOT really build anything — it must not report success:true.
1495
- success: (pipelineResult.success ?? this._state === OrchestratorState.COMPLETE) && !hasStubs,
1546
+ // A failed epic or a non-COMPLETE terminal state is likewise never a success.
1547
+ success:
1548
+ (pipelineResult.success ?? this._state === OrchestratorState.COMPLETE) &&
1549
+ !hasStubs &&
1550
+ !hasFailures &&
1551
+ !blocked,
1496
1552
  mode: hasStubs ? 'stub' : 'real',
1497
1553
  stubbedEpics: pipelineResult.epicsStubbed || [],
1498
1554
  ...(hasStubs