From d7e303d3c03c3dd96edbb9521ff15eaee9f3070b Mon Sep 17 00:00:00 2001 From: mos-dt-0 Date: Tue, 18 Aug 2026 05:56:38 +0000 Subject: [PATCH] =?UTF-8?q?fix(macp):=20fail-closed=20typed=20gate=20state?= =?UTF-8?q?s=20=E2=80=94=20RI-2-002=20(#1275)=20(#1293)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: mos-dt-0 --- packages/macp/__tests__/gate-runner.test.ts | 253 ------------ packages/macp/src/cli.spec.ts | 164 +++++++- packages/macp/src/cli.ts | 148 ++++++- packages/macp/src/errors.ts | 35 ++ packages/macp/src/gate-runner.spec.ts | 429 ++++++++++++++++++++ packages/macp/src/gate-runner.ts | 173 ++++++-- packages/macp/src/index.ts | 18 +- packages/macp/src/types.ts | 41 +- 8 files changed, 959 insertions(+), 302 deletions(-) delete mode 100644 packages/macp/__tests__/gate-runner.test.ts create mode 100644 packages/macp/src/errors.ts create mode 100644 packages/macp/src/gate-runner.spec.ts diff --git a/packages/macp/__tests__/gate-runner.test.ts b/packages/macp/__tests__/gate-runner.test.ts deleted file mode 100644 index ffe6029f..00000000 --- a/packages/macp/__tests__/gate-runner.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { mkdirSync, readFileSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { randomUUID } from 'node:crypto'; -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { normalizeGate, countAIFindings, runGate, runGates } from '../src/gate-runner.js'; - -function makeTmpDir(): string { - const dir = join(tmpdir(), `macp-gate-${randomUUID()}`); - mkdirSync(dir, { recursive: true }); - return dir; -} - -describe('normalizeGate', () => { - it('normalizes a string to mechanical gate', () => { - expect(normalizeGate('echo test')).toEqual({ - command: 'echo test', - type: 'mechanical', - fail_on: 'blocker', - }); - }); - - it('normalizes an object gate with defaults', () => { - expect(normalizeGate({ command: 'lint' })).toEqual({ - command: 'lint', - type: 'mechanical', - fail_on: 'blocker', - }); - }); - - it('preserves explicit type and fail_on', () => { - expect(normalizeGate({ command: 'review', type: 'ai-review', fail_on: 'any' })).toEqual({ - command: 'review', - type: 'ai-review', - fail_on: 'any', - }); - }); - - it('handles non-string/non-object input', () => { - expect(normalizeGate(42)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' }); - expect(normalizeGate(null)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' }); - }); -}); - -describe('countAIFindings', () => { - it('returns zeros for non-object', () => { - expect(countAIFindings(null)).toEqual({ blockers: 0, total: 0 }); - expect(countAIFindings('string')).toEqual({ blockers: 0, total: 0 }); - expect(countAIFindings([])).toEqual({ blockers: 0, total: 0 }); - }); - - it('counts from stats block', () => { - const output = { stats: { blockers: 2, should_fix: 3, suggestions: 1 } }; - expect(countAIFindings(output)).toEqual({ blockers: 2, total: 6 }); - }); - - it('counts from findings array when stats has no blockers', () => { - const output = { - stats: { blockers: 0 }, - findings: [{ severity: 'blocker' }, { severity: 'warning' }, { severity: 'blocker' }], - }; - expect(countAIFindings(output)).toEqual({ blockers: 2, total: 3 }); - }); - - it('uses stats blockers over findings array when stats has blockers', () => { - const output = { - stats: { blockers: 5 }, - findings: [{ severity: 'blocker' }, { severity: 'warning' }], - }; - // stats.blockers = 5, total from stats = 5+0+0 = 5, findings not used for total since stats total is non-zero - expect(countAIFindings(output)).toEqual({ blockers: 5, total: 5 }); - }); - - it('counts findings length as total when stats has zero total', () => { - const output = { - findings: [{ severity: 'warning' }, { severity: 'info' }], - }; - expect(countAIFindings(output)).toEqual({ blockers: 0, total: 2 }); - }); -}); - -describe('runGate', () => { - let tmp: string; - let logPath: string; - - beforeEach(() => { - tmp = makeTmpDir(); - logPath = join(tmp, 'gate.log'); - }); - - afterEach(() => { - rmSync(tmp, { recursive: true, force: true }); - }); - - it('passes mechanical gate on exit 0', () => { - const result = runGate('echo hello', tmp, logPath, 30); - expect(result.passed).toBe(true); - expect(result.exit_code).toBe(0); - expect(result.type).toBe('mechanical'); - expect(result.output).toContain('hello'); - }); - - it('fails mechanical gate on non-zero exit', () => { - const result = runGate('exit 1', tmp, logPath, 30); - expect(result.passed).toBe(false); - expect(result.exit_code).toBe(1); - }); - - it('ci-pipeline always passes', () => { - const result = runGate({ command: 'anything', type: 'ci-pipeline' }, tmp, logPath, 30); - expect(result.passed).toBe(true); - expect(result.type).toBe('ci-pipeline'); - expect(result.output).toBe('CI pipeline gate placeholder'); - }); - - it('empty command passes', () => { - const result = runGate({ command: '' }, tmp, logPath, 30); - expect(result.passed).toBe(true); - }); - - it('ai-review gate parses JSON output', () => { - const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } }); - const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30); - expect(result.passed).toBe(true); - expect(result.blockers).toBe(0); - expect(result.findings).toBe(1); - }); - - it('ai-review gate fails on blockers', () => { - const json = JSON.stringify({ stats: { blockers: 2 } }); - const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30); - expect(result.passed).toBe(false); - expect(result.blockers).toBe(2); - }); - - it('ai-review gate with fail_on=any fails on any findings', () => { - const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } }); - const result = runGate( - { command: `echo '${json}'`, type: 'ai-review', fail_on: 'any' }, - tmp, - logPath, - 30, - ); - expect(result.passed).toBe(false); - expect(result.fail_on).toBe('any'); - }); - - it('ai-review gate fails on invalid JSON output', () => { - const result = runGate({ command: 'echo "not json"', type: 'ai-review' }, tmp, logPath, 30); - expect(result.passed).toBe(false); - expect(result.parse_error).toBeDefined(); - }); - - it('writes to log file', () => { - runGate('echo logged', tmp, logPath, 30); - const log = readFileSync(logPath, 'utf-8'); - expect(log).toContain('COMMAND: echo logged'); - expect(log).toContain('logged'); - expect(log).toContain('EXIT:'); - }); -}); - -describe('runGates', () => { - let tmp: string; - let logPath: string; - let eventsPath: string; - - beforeEach(() => { - tmp = makeTmpDir(); - logPath = join(tmp, 'gates.log'); - eventsPath = join(tmp, 'events.ndjson'); - }); - - afterEach(() => { - rmSync(tmp, { recursive: true, force: true }); - }); - - it('runs multiple gates and returns results', () => { - const { allPassed, gateResults } = runGates( - ['echo one', 'echo two'], - tmp, - logPath, - 30, - eventsPath, - 'task-1', - ); - expect(allPassed).toBe(true); - expect(gateResults).toHaveLength(2); - }); - - it('reports failure when any gate fails', () => { - const { allPassed, gateResults } = runGates( - ['echo ok', 'exit 1'], - tmp, - logPath, - 30, - eventsPath, - 'task-2', - ); - expect(allPassed).toBe(false); - expect(gateResults[0]!.passed).toBe(true); - expect(gateResults[1]!.passed).toBe(false); - }); - - it('emits events for each gate', () => { - runGates(['echo test'], tmp, logPath, 30, eventsPath, 'task-3'); - const events = readFileSync(eventsPath, 'utf-8') - .trim() - .split('\n') - .map((l) => JSON.parse(l)); - expect(events).toHaveLength(2); // started + passed - expect(events[0].event_type).toBe('rail.check.started'); - expect(events[1].event_type).toBe('rail.check.passed'); - }); - - it('skips gates with empty command (non ci-pipeline)', () => { - const { gateResults } = runGates( - [{ command: '', type: 'mechanical' }, 'echo real'], - tmp, - logPath, - 30, - eventsPath, - 'task-4', - ); - expect(gateResults).toHaveLength(1); - }); - - it('does not skip ci-pipeline even with empty command', () => { - const { gateResults } = runGates( - [{ command: '', type: 'ci-pipeline' }], - tmp, - logPath, - 30, - eventsPath, - 'task-5', - ); - expect(gateResults).toHaveLength(1); - expect(gateResults[0]!.passed).toBe(true); - }); - - it('emits failed event with correct message', () => { - runGates(['exit 42'], tmp, logPath, 30, eventsPath, 'task-6'); - const events = readFileSync(eventsPath, 'utf-8') - .trim() - .split('\n') - .map((l) => JSON.parse(l)); - const failEvent = events.find( - (e: Record) => e.event_type === 'rail.check.failed', - ); - expect(failEvent).toBeDefined(); - expect(failEvent.message).toContain('Gate failed ('); - }); -}); diff --git a/packages/macp/src/cli.spec.ts b/packages/macp/src/cli.spec.ts index 4ee920b9..b4b6c095 100644 --- a/packages/macp/src/cli.spec.ts +++ b/packages/macp/src/cli.spec.ts @@ -1,5 +1,8 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { Command } from 'commander'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { registerMacpCommand } from './cli.js'; describe('registerMacpCommand', () => { @@ -75,3 +78,162 @@ describe('registerMacpCommand', () => { expect(topLevel).toContain('events'); }); }); + +/** + * RI-N2 fail-closed CLI behavior: an unimplemented capability is a failure, + * never a success. Every stub exits nonzero with a typed message, and the + * implemented `macp gate` mirrors the typed gate-runner states. + */ +describe('registerMacpCommand fail-closed (RI-N2)', () => { + let tmpDir: string; + + function buildProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeErr: () => {} }); + registerMacpCommand(program); + return program; + } + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'macp-cli-failclosed-')); + process.exitCode = 0; + }); + + afterEach(() => { + process.exitCode = 0; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('macp tasks list exits nonzero (unimplemented capability)', async () => { + const program = buildProgram(); + await program.parseAsync(['macp', 'tasks', 'list'], { from: 'user' }); + expect(process.exitCode).not.toBe(0); + }); + + it('macp submit exits nonzero with a typed MACP_NOT_IMPLEMENTED message', async () => { + const program = buildProgram(); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await program.parseAsync(['macp', 'submit', 'spec.json'], { from: 'user' }); + expect(process.exitCode).not.toBe(0); + const errText = errSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(errText).toContain('MACP_NOT_IMPLEMENTED'); + } finally { + errSpy.mockRestore(); + } + }); + + it('macp events tail exits nonzero (unimplemented capability)', async () => { + const program = buildProgram(); + await program.parseAsync(['macp', 'events', 'tail'], { from: 'user' }); + expect(process.exitCode).not.toBe(0); + }); + + it('macp gate runs a green inline command and exits 0', async () => { + const program = buildProgram(); + await program.parseAsync( + [ + 'macp', + 'gate', + 'exit 0', + '--cwd', + tmpDir, + '--log', + path.join(tmpDir, 'g.log'), + '--timeout', + '10', + ], + { from: 'user' }, + ); + expect(process.exitCode).toBe(0); + }); + + it('macp gate exits nonzero on a failing command', async () => { + const program = buildProgram(); + await program.parseAsync( + [ + 'macp', + 'gate', + 'exit 9', + '--cwd', + tmpDir, + '--log', + path.join(tmpDir, 'g.log'), + '--timeout', + '10', + ], + { from: 'user' }, + ); + expect(process.exitCode).not.toBe(0); + }); + + it('macp gate with an unimplemented ci-pipeline capability exits nonzero', async () => { + const program = buildProgram(); + const specPath = path.join(tmpDir, 'gates.json'); + fs.writeFileSync(specPath, JSON.stringify([{ type: 'ci-pipeline' }])); + await program.parseAsync( + [ + 'macp', + 'gate', + specPath, + '--cwd', + tmpDir, + '--log', + path.join(tmpDir, 'g.log'), + '--timeout', + '10', + ], + { from: 'user' }, + ); + expect(process.exitCode).not.toBe(0); + }); + + it('macp gate --simulate completes (exit 0) but reports simulated results', async () => { + const program = buildProgram(); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + await program.parseAsync( + [ + 'macp', + 'gate', + 'exit 0', + '--simulate', + '--cwd', + tmpDir, + '--log', + path.join(tmpDir, 'g.log'), + '--timeout', + '10', + ], + { from: 'user' }, + ); + // completes only because the caller explicitly asked to simulate + expect(process.exitCode).toBe(0); + const outText = logSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(outText).toContain('simulated'); + expect(outText).toContain('SIMULATED'); + } finally { + logSpy.mockRestore(); + } + }); + + it('macp gate with an empty spec exits nonzero with a typed error', async () => { + const program = buildProgram(); + await program.parseAsync( + [ + 'macp', + 'gate', + ' ', + '--cwd', + tmpDir, + '--log', + path.join(tmpDir, 'g.log'), + '--timeout', + '10', + ], + { from: 'user' }, + ); + expect(process.exitCode).not.toBe(0); + }); +}); diff --git a/packages/macp/src/cli.ts b/packages/macp/src/cli.ts index d4e26940..cfeb2d6e 100644 --- a/packages/macp/src/cli.ts +++ b/packages/macp/src/cli.ts @@ -1,5 +1,73 @@ +import { existsSync, readFileSync } from 'node:fs'; + import type { Command } from 'commander'; +import { runGates } from './gate-runner.js'; +import { MACPCapabilityError, type MacpErrorCode } from './errors.js'; + +/** + * Load gates from a spec: an existing file (JSON gates array, a JSON object + * with `quality_gates`, a JSON gate object, or one command per line) or an + * inline command string. Fails closed with a typed capability error when the + * spec contains no executable gate definition. + */ +function loadGateSpec(spec: string): unknown[] { + if (existsSync(spec)) { + const raw = readFileSync(spec, 'utf-8'); + try { + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed)) { + if (parsed.length === 0) { + throw new MACPCapabilityError( + 'MACP_NO_COMMAND', + 'gate-spec', + `gate spec file '${spec}' contains an empty gates array`, + ); + } + return parsed; + } + if (typeof parsed === 'object' && parsed !== null) { + const obj = parsed as Record; + if (Array.isArray(obj['quality_gates'])) { + return obj['quality_gates']; + } + return [parsed]; + } + throw new MACPCapabilityError( + 'MACP_NO_COMMAND', + 'gate-spec', + `gate spec file '${spec}' parsed to ${typeof parsed} — expected a gates array, a task with quality_gates, or a gate object`, + ); + } catch (exc) { + if (exc instanceof MACPCapabilityError) throw exc; + // Not JSON — treat each non-empty line as a command gate. + const lines = raw + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); + if (lines.length > 0) return lines; + throw new MACPCapabilityError( + 'MACP_NO_COMMAND', + 'gate-spec', + `gate spec file '${spec}' contains no gates`, + ); + } + } + if (spec.trim().length > 0) return [spec]; + throw new MACPCapabilityError('MACP_NO_COMMAND', 'gate-spec', 'gate spec is empty'); +} + +/** Print a typed not-implemented failure and exit nonzero (RI-N2 fail-closed). */ +function notImplemented(subcommand: string, capability: string, hint: string): void { + const err = new MACPCapabilityError( + 'MACP_NOT_IMPLEMENTED', + capability, + `${subcommand} is not implemented in @mosaicstack/macp yet (${capability} capability absent) — ${hint}`, + ); + console.error(`[macp] ${subcommand}: ${err.message} [${err.code}]`); + process.exitCode = 1; +} + /** * Register macp subcommands on an existing Commander program. * This avoids cross-package Commander version mismatches by using the @@ -24,15 +92,14 @@ export function registerMacpCommand(parent: Command): void { 'Filter by task type (coding|deploy|research|review|documentation|infrastructure)', ) .action((opts: { status?: string; type?: string }) => { - // not yet wired — task persistence layer is not present in @mosaicstack/macp - console.log('[macp] tasks list: not yet wired — use macp package programmatically'); + // unimplemented capability — a failure, never a success (RI-N2) if (opts.status) { console.log(` status filter: ${opts.status}`); } if (opts.type) { console.log(` type filter: ${opts.type}`); } - process.exitCode = 0; + notImplemented('tasks list', 'task-persistence', 'use the macp package programmatically'); }); // ─── submit ────────────────────────────────────────────────────────────── @@ -41,12 +108,11 @@ export function registerMacpCommand(parent: Command): void { .command('submit ') .description('Submit a task from a JSON/YAML spec file') .action((specPath: string) => { - // not yet wired — task submission requires a running MACP server - console.log('[macp] submit: not yet wired — use macp package programmatically'); + // unimplemented capability — a failure, never a success (RI-N2) console.log(` spec path: ${specPath}`); console.log(' task id: (unavailable — no MACP server connected)'); console.log(' status: (unavailable — no MACP server connected)'); - process.exitCode = 0; + notImplemented('submit', 'macp-server', 'use the macp package programmatically'); }); // ─── gate ──────────────────────────────────────────────────────────────── @@ -58,16 +124,58 @@ export function registerMacpCommand(parent: Command): void { .option('--cwd ', 'Working directory for gate execution', process.cwd()) .option('--log ', 'Path to write gate log output', '/tmp/macp-gate.log') .option('--timeout ', 'Gate timeout in seconds', '60') - .action((spec: string, opts: { failOn: string; cwd: string; log: string; timeout: string }) => { - // not yet wired — gate execution requires a task context and event sink - console.log('[macp] gate: not yet wired — use macp package programmatically'); - console.log(` spec: ${spec}`); - console.log(` fail-on: ${opts.failOn}`); - console.log(` cwd: ${opts.cwd}`); - console.log(` log: ${opts.log}`); - console.log(` timeout: ${opts.timeout}s`); - process.exitCode = 0; - }); + .option( + '--simulate', + 'Simulate gates instead of executing them; results are typed simulated and never satisfy a check', + ) + .action( + ( + spec: string, + opts: { failOn: string; cwd: string; log: string; timeout: string; simulate?: boolean }, + ) => { + let gates: unknown[]; + try { + gates = loadGateSpec(spec); + } catch (exc) { + if (exc instanceof MACPCapabilityError) { + console.error(`[macp] gate: ${exc.message} [${exc.code}]`); + } else { + console.error(`[macp] gate: ${String(exc)}`); + } + process.exitCode = 1; + return; + } + + const timeoutSec = Number.parseInt(opts.timeout, 10) || 60; + const eventsPath = `${opts.log}.events.ndjson`; + const { state, gateResults } = runGates( + gates, + opts.cwd, + opts.log, + timeoutSec, + eventsPath, + 'macp-cli-gate', + { + simulate: opts.simulate, + }, + ); + + for (const r of gateResults) { + const label = r.command || r.type; + const reason = r.reason ? ` — ${r.reason}` : ''; + console.log(`[macp] gate ${r.status}: ${label}${reason}`); + } + if (opts.simulate) { + console.log( + '[macp] SIMULATED run — every result is typed simulated and can never satisfy a gate, dependency, or release check', + ); + } + + // Simulated runs may complete (exit 0) only because the caller + // explicitly passed --simulate; the typed state stays 'simulated'. + process.exitCode = state === 'passed' || state === 'simulated' ? 0 : 1; + }, + ); // ─── events ────────────────────────────────────────────────────────────── @@ -79,14 +187,16 @@ export function registerMacpCommand(parent: Command): void { .option('--file ', 'Path to the MACP events NDJSON file') .option('--follow', 'Follow the file for new events (like tail -f)') .action((opts: { file?: string; follow?: boolean }) => { - // not yet wired — event streaming requires a live event source - console.log('[macp] events tail: not yet wired — use macp package programmatically'); + // unimplemented capability — a failure, never a success (RI-N2) if (opts.file) { console.log(` file: ${opts.file}`); } if (opts.follow) { console.log(' mode: follow'); } - process.exitCode = 0; + notImplemented('events tail', 'event-source', 'use the macp package programmatically'); }); } + +// Re-export so CLI consumers can surface typed capability codes. +export type { MacpErrorCode }; diff --git a/packages/macp/src/errors.ts b/packages/macp/src/errors.ts new file mode 100644 index 00000000..de5aae35 --- /dev/null +++ b/packages/macp/src/errors.ts @@ -0,0 +1,35 @@ +/** Typed error code from the closed MACP_ERROR_CODES set. */ +export type MacpErrorCode = (typeof MACP_ERROR_CODES)[number]; +/** + * Typed fail-closed capability errors (RI-N2, SDLC-D-035). + * + * MACP must fail closed when a required capability (executor, reviewer, + * command, CI provider, human authority) is absent. These typed codes mirror + * the Forge failure vocabulary (FORGE_NO_*) so both packages speak the same + * language: an unimplemented capability is a failure, never a stub success. + */ + +/** Closed set of typed MACP capability error codes. */ +export const MACP_ERROR_CODES = [ + 'MACP_NOT_IMPLEMENTED', + 'MACP_NO_COMMAND', + 'MACP_NO_REVIEWER', + 'MACP_NO_CI_PIPELINE', + 'MACP_NO_PROVIDER', + 'MACP_AUTHORITY_REQUIRED', +] as const; + +/** Raised when a required capability is missing and execution must fail closed. */ +export class MACPCapabilityError extends Error { + /** Typed error code from the closed MACP_ERROR_CODES set. */ + readonly code: MacpErrorCode; + /** The missing capability, e.g. `ci-provider`, `task-persistence`, `command`. */ + readonly capability: string; + + constructor(code: MacpErrorCode, capability: string, message: string) { + super(message); + this.name = 'MACPCapabilityError'; + this.code = code; + this.capability = capability; + } +} diff --git a/packages/macp/src/gate-runner.spec.ts b/packages/macp/src/gate-runner.spec.ts new file mode 100644 index 00000000..45c01a5d --- /dev/null +++ b/packages/macp/src/gate-runner.spec.ts @@ -0,0 +1,429 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { countAIFindings, normalizeGate, runGate, runGates } from './gate-runner.js'; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'macp-gate-')); +} + +describe('normalizeGate', () => { + it('normalizes a string to mechanical gate', () => { + expect(normalizeGate('echo test')).toEqual({ + command: 'echo test', + type: 'mechanical', + fail_on: 'blocker', + }); + }); + + it('normalizes an object gate with defaults', () => { + expect(normalizeGate({ command: 'lint' })).toEqual({ + command: 'lint', + type: 'mechanical', + fail_on: 'blocker', + }); + }); + + it('preserves explicit type and fail_on', () => { + expect(normalizeGate({ command: 'review', type: 'ai-review', fail_on: 'any' })).toEqual({ + command: 'review', + type: 'ai-review', + fail_on: 'any', + }); + }); + + it('handles non-string/non-object input', () => { + expect(normalizeGate(42)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' }); + expect(normalizeGate(null)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' }); + }); +}); + +describe('countAIFindings', () => { + it('returns zeros for non-object', () => { + expect(countAIFindings(null)).toEqual({ blockers: 0, total: 0 }); + expect(countAIFindings('string')).toEqual({ blockers: 0, total: 0 }); + expect(countAIFindings([])).toEqual({ blockers: 0, total: 0 }); + }); + + it('counts from stats block', () => { + const output = { stats: { blockers: 2, should_fix: 3, suggestions: 1 } }; + expect(countAIFindings(output)).toEqual({ blockers: 2, total: 6 }); + }); + + it('counts from findings array when stats has no blockers', () => { + const output = { + stats: { blockers: 0 }, + findings: [{ severity: 'blocker' }, { severity: 'warning' }, { severity: 'blocker' }], + }; + expect(countAIFindings(output)).toEqual({ blockers: 2, total: 3 }); + }); + + it('uses stats blockers over findings array when stats has blockers', () => { + const output = { + stats: { blockers: 5 }, + findings: [{ severity: 'blocker' }, { severity: 'warning' }], + }; + // stats.blockers = 5, total from stats = 5+0+0 = 5, findings not used for total since stats total is non-zero + expect(countAIFindings(output)).toEqual({ blockers: 5, total: 5 }); + }); + + it('counts findings length as total when stats has zero total', () => { + const output = { + findings: [{ severity: 'warning' }, { severity: 'info' }], + }; + expect(countAIFindings(output)).toEqual({ blockers: 0, total: 2 }); + }); +}); + +describe('runGate', () => { + let tmp: string; + let logPath: string; + + beforeEach(() => { + tmp = makeTmpDir(); + logPath = path.join(tmp, 'gate.log'); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('passes mechanical gate on exit 0', () => { + const result = runGate('echo hello', tmp, logPath, 30); + expect(result.passed).toBe(true); + expect(result.exit_code).toBe(0); + expect(result.type).toBe('mechanical'); + expect(result.output).toContain('hello'); + }); + + it('fails mechanical gate on non-zero exit', () => { + const result = runGate('exit 1', tmp, logPath, 30); + expect(result.passed).toBe(false); + expect(result.exit_code).toBe(1); + }); + + it('ci-pipeline fails closed without a CI provider (no placeholder pass)', () => { + const result = runGate({ command: 'anything', type: 'ci-pipeline' }, tmp, logPath, 30); + expect(result.passed).toBe(false); + expect(result.status).toBe('capability_failure'); + expect(result.capability_code).toBe('MACP_NO_CI_PIPELINE'); + expect(result.type).toBe('ci-pipeline'); + expect(result.output).not.toBe('CI pipeline gate placeholder'); + }); + + it('empty command is a typed capability failure, never a pass', () => { + const result = runGate({ command: '' }, tmp, logPath, 30); + expect(result.passed).toBe(false); + expect(result.status).toBe('capability_failure'); + expect(result.capability_code).toBe('MACP_NO_COMMAND'); + }); + + it('ai-review gate parses JSON output', () => { + const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } }); + const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30); + expect(result.passed).toBe(true); + expect(result.blockers).toBe(0); + expect(result.findings).toBe(1); + }); + + it('ai-review gate fails on blockers', () => { + const json = JSON.stringify({ stats: { blockers: 2 } }); + const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30); + expect(result.passed).toBe(false); + expect(result.blockers).toBe(2); + }); + + it('ai-review gate with fail_on=any fails on any findings', () => { + const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } }); + const result = runGate( + { command: `echo '${json}'`, type: 'ai-review', fail_on: 'any' }, + tmp, + logPath, + 30, + ); + expect(result.passed).toBe(false); + expect(result.fail_on).toBe('any'); + }); + + it('ai-review gate fails on invalid JSON output', () => { + const result = runGate({ command: 'echo "not json"', type: 'ai-review' }, tmp, logPath, 30); + expect(result.passed).toBe(false); + expect(result.parse_error).toBeDefined(); + }); + + it('writes to log file', () => { + runGate('echo logged', tmp, logPath, 30); + const log = fs.readFileSync(logPath, 'utf-8'); + expect(log).toContain('COMMAND: echo logged'); + expect(log).toContain('logged'); + expect(log).toContain('EXIT:'); + }); +}); + +describe('runGates', () => { + let tmp: string; + let logPath: string; + let eventsPath: string; + + beforeEach(() => { + tmp = makeTmpDir(); + logPath = path.join(tmp, 'gates.log'); + eventsPath = path.join(tmp, 'events.ndjson'); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('runs multiple gates and returns results', () => { + const { allPassed, gateResults } = runGates( + ['echo one', 'echo two'], + tmp, + logPath, + 30, + eventsPath, + 'task-1', + ); + expect(allPassed).toBe(true); + expect(gateResults).toHaveLength(2); + }); + + it('reports failure when any gate fails', () => { + const { allPassed, gateResults } = runGates( + ['echo ok', 'exit 1'], + tmp, + logPath, + 30, + eventsPath, + 'task-2', + ); + expect(allPassed).toBe(false); + expect(gateResults[0]!.passed).toBe(true); + expect(gateResults[1]!.passed).toBe(false); + }); + + it('emits events for each gate', () => { + runGates(['echo test'], tmp, logPath, 30, eventsPath, 'task-3'); + const events = fs + .readFileSync(eventsPath, 'utf-8') + .trim() + .split('\n') + .map((l) => JSON.parse(l)); + expect(events).toHaveLength(2); // started + passed + expect(events[0].event_type).toBe('rail.check.started'); + expect(events[1].event_type).toBe('rail.check.passed'); + }); + + it('does not silently skip gates with empty command — they become capability failures', () => { + const { gateResults, allPassed, state } = runGates( + [{ command: '', type: 'mechanical' }, 'echo real'], + tmp, + logPath, + 30, + eventsPath, + 'task-4', + ); + expect(gateResults).toHaveLength(2); + expect(gateResults[0]!.status).toBe('capability_failure'); + expect(gateResults[1]!.status).toBe('passed'); + expect(allPassed).toBe(false); + expect(state).toBe('capability_failure'); + }); + + it('does not skip ci-pipeline even with empty command — typed capability failure', () => { + const { gateResults, allPassed, state } = runGates( + [{ command: '', type: 'ci-pipeline' }], + tmp, + logPath, + 30, + eventsPath, + 'task-5', + ); + expect(gateResults).toHaveLength(1); + expect(gateResults[0]!.passed).toBe(false); + expect(gateResults[0]!.status).toBe('capability_failure'); + expect(allPassed).toBe(false); + expect(state).toBe('capability_failure'); + }); + + it('emits failed event with correct message', () => { + runGates(['exit 42'], tmp, logPath, 30, eventsPath, 'task-6'); + const events = fs + .readFileSync(eventsPath, 'utf-8') + .trim() + .split('\n') + .map((l) => JSON.parse(l)); + const failEvent = events.find( + (e: Record) => e.event_type === 'rail.check.failed', + ); + expect(failEvent).toBeDefined(); + expect(failEvent.message).toContain('Gate failed ('); + }); +}); + +/** + * RI-N2 / SDLC-D-035 fail-closed controls for the MACP gate runner. + * + * Invariant under test: `passed: true` occurs ONLY when a gate really executed + * and really exited green (`status === 'passed'`). Absent capabilities, + * manual sign-offs, and simulated runs are typed distinctly and can never + * make the aggregate `passed`. + */ +describe('gate-runner fail-closed (RI-N2)', () => { + let tmpDir: string; + let logPath: string; + let eventsPath: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + logPath = path.join(tmpDir, 'gate.log'); + eventsPath = path.join(tmpDir, 'events.ndjson'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function run(gates: unknown[], options?: { simulate?: boolean }) { + return runGates(gates, tmpDir, logPath, 10, eventsPath, 'spec-task', options); + } + + // ─── positive controls ─────────────────────────────────────────────────── + + it('a really-executed green command gate still passes', () => { + const result = run([{ command: 'exit 0', type: 'mechanical' }]); + expect(result.gateResults[0]!.status).toBe('passed'); + expect(result.gateResults[0]!.passed).toBe(true); + expect(result.allPassed).toBe(true); + expect(result.state).toBe('passed'); + }); + + it('explicit simulate completes and types every result simulated', () => { + const result = run([{ command: 'exit 0', type: 'mechanical' }, 'echo hello'], { + simulate: true, + }); + expect(result.gateResults).toHaveLength(2); + for (const gate of result.gateResults) { + expect(gate.status).toBe('simulated'); + expect(gate.passed).toBe(false); + } + expect(result.state).toBe('simulated'); + }); + + it('a really-executed red command gate fails with typed status failed', () => { + const result = run([{ command: 'exit 3', type: 'mechanical' }]); + expect(result.gateResults[0]!.status).toBe('failed'); + expect(result.gateResults[0]!.passed).toBe(false); + expect(result.allPassed).toBe(false); + expect(result.state).toBe('failed'); + }); + + // ─── negative controls — each asserts typed status AND aggregate not passed ── + + it('an empty-command gate is a capability_failure, not skipped and not passed', () => { + const result = run([{ command: '', type: 'mechanical' }]); + // runGates must not silently skip it — it produces a typed result + expect(result.gateResults).toHaveLength(1); + const gate = result.gateResults[0]!; + expect(gate.status).toBe('capability_failure'); + expect(gate.capability_code).toBe('MACP_NO_COMMAND'); + expect(gate.passed).toBe(false); + // aggregate is not passed + expect(result.allPassed).toBe(false); + expect(result.state).toBe('capability_failure'); + expect(result.state).not.toBe('passed'); + }); + + it('a commandless ai-review gate is a typed MACP_NO_REVIEWER capability_failure', () => { + const result = run([{ command: '', type: 'ai-review' }]); + expect(result.gateResults[0]!.status).toBe('capability_failure'); + expect(result.gateResults[0]!.capability_code).toBe('MACP_NO_REVIEWER'); + expect(result.allPassed).toBe(false); + expect(result.state).not.toBe('passed'); + }); + + it('a ci-pipeline gate without a provider implementation is a capability_failure, never a placeholder pass', () => { + const result = run([{ command: '', type: 'ci-pipeline' }]); + const gate = result.gateResults[0]!; + expect(gate.status).toBe('capability_failure'); + expect(gate.capability_code).toBe('MACP_NO_CI_PIPELINE'); + expect(gate.passed).toBe(false); + // the old false-success placeholder must be gone + expect(gate.output).not.toBe('CI pipeline gate placeholder'); + expect(result.allPassed).toBe(false); + expect(result.state).not.toBe('passed'); + }); + + it('a ci-pipeline gate fails closed even alongside an otherwise green run', () => { + const result = run(['exit 0', { type: 'ci-pipeline', command: 'fake-ci' }]); + expect(result.gateResults[1]!.status).toBe('capability_failure'); + expect(result.gateResults[0]!.status).toBe('passed'); + expect(result.allPassed).toBe(false); + expect(result.state).toBe('capability_failure'); + }); + + it('a manual gate with no automation enters typed waiting — neither pass nor fail', () => { + const result = run([{ type: 'manual' }]); + const gate = result.gateResults[0]!; + expect(gate.status).toBe('waiting'); + expect(gate.passed).toBe(false); + expect(gate.exit_code).toBe(0); + // aggregate is not passed while any gate is waiting + expect(result.allPassed).toBe(false); + expect(result.state).toBe('waiting'); + expect(result.state).not.toBe('passed'); + }); + + it('a simulated result can never make the aggregate passed', () => { + const result = run(['exit 0', 'exit 0'], { simulate: true }); + expect(result.gateResults.every((g) => g.status === 'simulated')).toBe(true); + expect(result.allPassed).toBe(false); + expect(result.state).toBe('simulated'); + expect(result.state).not.toBe('passed'); + }); + + it('waiting dominates an otherwise green aggregate', () => { + const result = run(['exit 0', { type: 'manual' }]); + expect(result.allPassed).toBe(false); + expect(result.state).toBe('waiting'); + }); +}); + +describe('runGate fail-closed (RI-N2)', () => { + let tmpDir: string; + let logPath: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + logPath = path.join(tmpDir, 'gate.log'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('simulate: true returns a typed simulated result without executing', () => { + const result = runGate('this-command-does-not-exist-xyz', tmpDir, logPath, 10, { + simulate: true, + }); + expect(result.status).toBe('simulated'); + expect(result.passed).toBe(false); + expect(result.exit_code).toBe(0); + }); + + it('normal mode executes for real and types a green gate passed', () => { + const result = runGate('echo ok', tmpDir, logPath, 10); + expect(result.status).toBe('passed'); + expect(result.passed).toBe(true); + expect(result.output).toContain('ok'); + }); + + it('a bare string gate normalizes to mechanical and executes', () => { + const result = runGate('exit 7', tmpDir, logPath, 10); + expect(result.type).toBe('mechanical'); + expect(result.status).toBe('failed'); + expect(result.passed).toBe(false); + }); +}); diff --git a/packages/macp/src/gate-runner.ts b/packages/macp/src/gate-runner.ts index b59f8078..1ba2266f 100644 --- a/packages/macp/src/gate-runner.ts +++ b/packages/macp/src/gate-runner.ts @@ -4,7 +4,20 @@ import { dirname } from 'node:path'; import { emitEvent } from './event-emitter.js'; import { nowISO } from './event-emitter.js'; -import type { GateResult } from './types.js'; +import type { GateResult, GateStatus, RunGatesResult } from './types.js'; + +/** Typed reason stamped on every simulated gate result. */ +export const SIMULATED_GATE_REASON = + 'simulated execution (explicit simulate opt-in): gate was not evaluated by a real implementation'; + +/** Options for gate execution (RI-N2 fail-closed / explicit simulation). */ +export interface RunGateOptions { + /** + * Explicit caller opt-in to simulation. Simulated gates are NOT executed; + * every result is typed `simulated` and never satisfies anything. + */ + simulate?: boolean; +} export interface NormalizedGate { command: string; @@ -103,36 +116,91 @@ export function countAIFindings(parsedOutput: unknown): { blockers: number; tota return { blockers, total }; } +function simulatedResult(gateEntry: NormalizedGate): GateResult { + return { + command: gateEntry.command, + exit_code: 0, + type: gateEntry.type, + output: SIMULATED_GATE_REASON, + timed_out: false, + passed: false, + status: 'simulated', + reason: SIMULATED_GATE_REASON, + }; +} + +function capabilityFailureResult( + gateEntry: NormalizedGate, + code: GateResult['capability_code'], + reason: string, +): GateResult { + return { + command: gateEntry.command, + exit_code: 1, + type: gateEntry.type, + output: '', + timed_out: false, + passed: false, + status: 'capability_failure', + capability_code: code, + reason, + }; +} + +function waitingResult(gateEntry: NormalizedGate, reason: string): GateResult { + return { + command: gateEntry.command, + exit_code: 0, + type: gateEntry.type, + output: '', + timed_out: false, + passed: false, + status: 'waiting', + capability_code: 'MACP_AUTHORITY_REQUIRED', + reason, + }; +} + export function runGate( gate: unknown, cwd: string, logPath: string, timeoutSec: number, + options: RunGateOptions = {}, ): GateResult { const gateEntry = normalizeGate(gate); const gateType = gateEntry.type; const command = gateEntry.command; + // Explicit simulation only: never executes, typed simulated, never satisfying. + if (options.simulate) { + return simulatedResult(gateEntry); + } + + // Fail closed: no CI provider implementation exists in @mosaicstack/macp, + // so a ci-pipeline gate is an absent capability — never a placeholder pass. if (gateType === 'ci-pipeline') { - return { - command, - exit_code: 0, - type: gateType, - output: 'CI pipeline gate placeholder', - timed_out: false, - passed: true, - }; + return capabilityFailureResult( + gateEntry, + 'MACP_NO_CI_PIPELINE', + `ci-pipeline gate '${gateEntry.command || gateType}' has no CI provider implementation wired — refusing placeholder pass`, + ); } if (!command) { - return { - command: '', - exit_code: 0, - type: gateType, - output: '', - timed_out: false, - passed: true, - }; + // A manual gate with no automation waits for human sign-off: not pass, not fail. + if (gateType === 'manual') { + return waitingResult( + gateEntry, + `manual gate has no automation — waiting for human sign-off (type: ${gateType})`, + ); + } + // Any other commandless gate is an absent capability — never a vacuous pass. + return capabilityFailureResult( + gateEntry, + gateType === 'ai-review' ? 'MACP_NO_REVIEWER' : 'MACP_NO_COMMAND', + `gate of type '${gateType}' has no command to execute — refusing empty-command pass`, + ); } const { exitCode, output, timedOut } = runShell(command, cwd, logPath, timeoutSec); @@ -143,10 +211,12 @@ export function runGate( output, timed_out: timedOut, passed: false, + status: 'failed', }; if (gateType !== 'ai-review') { result.passed = exitCode === 0; + result.status = result.passed ? 'passed' : 'failed'; return result; } @@ -170,6 +240,7 @@ export function runGate( } else { result.passed = exitCode === 0 && blockers === 0 && !timedOut && parseError === undefined; } + result.status = result.passed ? 'passed' : 'failed'; result.fail_on = failOn; result.blockers = blockers; @@ -191,16 +262,19 @@ export function runGates( timeoutSec: number, eventsPath: string, taskId: string, -): { allPassed: boolean; gateResults: GateResult[] } { - let allPassed = true; + options: RunGateOptions = {}, +): RunGatesResult { const gateResults: GateResult[] = []; + let hasCapabilityFailure = false; + let hasSimulated = false; + let hasFailed = false; + let hasWaiting = false; for (const gate of gates) { const gateEntry = normalizeGate(gate); const gateCmd = gateEntry.command; - if (!gateCmd && gateEntry.type !== 'ci-pipeline') continue; - const label = gateCmd || gateEntry.type; + // NOTE: no silent skip — every gate produces a typed result (RI-N2). emitEvent( eventsPath, 'rail.check.started', @@ -209,10 +283,10 @@ export function runGates( 'quality-gate', `Running gate: ${label}`, ); - const result = runGate(gate, cwd, logPath, timeoutSec); + const result = runGate(gate, cwd, logPath, timeoutSec, options); gateResults.push(result); - if (result.passed) { + if (result.status === 'passed') { emitEvent( eventsPath, 'rail.check.passed', @@ -224,7 +298,46 @@ export function runGates( continue; } - allPassed = false; + if (result.status === 'waiting') { + hasWaiting = true; + emitEvent( + eventsPath, + 'rail.check.waiting', + taskId, + 'gated', + 'quality-gate', + `Gate waiting: ${label} — ${result.reason ?? 'manual gate awaits sign-off'}`, + ); + continue; + } + + if (result.status === 'simulated') { + hasSimulated = true; + emitEvent( + eventsPath, + 'rail.check.simulated', + taskId, + 'gated', + 'quality-gate', + `Gate simulated (non-satisfying): ${label}`, + ); + continue; + } + + if (result.status === 'capability_failure') { + hasCapabilityFailure = true; + emitEvent( + eventsPath, + 'rail.check.failed', + taskId, + 'gated', + 'quality-gate', + `Gate capability failure (${result.capability_code ?? 'MACP_NO_PROVIDER'}): ${label} — ${result.reason ?? 'required capability is absent'}`, + ); + continue; + } + + hasFailed = true; let message: string; if (result.timed_out) { message = `Gate timed out after ${timeoutSec}s: ${label}`; @@ -236,5 +349,15 @@ export function runGates( emitEvent(eventsPath, 'rail.check.failed', taskId, 'gated', 'quality-gate', message); } - return { allPassed, gateResults }; + const state: GateStatus = hasCapabilityFailure + ? 'capability_failure' + : hasSimulated + ? 'simulated' + : hasFailed + ? 'failed' + : hasWaiting + ? 'waiting' + : 'passed'; + + return { allPassed: state === 'passed', gateResults, state }; } diff --git a/packages/macp/src/index.ts b/packages/macp/src/index.ts index a510b9a5..ad809d65 100644 --- a/packages/macp/src/index.ts +++ b/packages/macp/src/index.ts @@ -6,11 +6,13 @@ export type { DependsOnPolicy, GateType, GateFailOn, + GateStatus, GateEntry, Task, EventType, MACPEvent, GateResult, + RunGatesResult, TaskResult, ProviderMeta, ProviderRegistry, @@ -18,6 +20,11 @@ export type { export { CredentialError } from './types.js'; +// Typed fail-closed capability errors (RI-N2, SDLC-D-035) +export { MACP_ERROR_CODES, MACPCapabilityError } from './errors.js'; + +export type { MacpErrorCode } from './errors.js'; + // Credential resolver export { DEFAULT_CREDENTIALS_DIR, @@ -35,9 +42,16 @@ export { export type { ResolveCredentialsOptions } from './credential-resolver.js'; // Gate runner -export { normalizeGate, runShell, countAIFindings, runGate, runGates } from './gate-runner.js'; +export { + normalizeGate, + runShell, + countAIFindings, + runGate, + runGates, + SIMULATED_GATE_REASON, +} from './gate-runner.js'; -export type { NormalizedGate } from './gate-runner.js'; +export type { NormalizedGate, RunGateOptions } from './gate-runner.js'; // Risk-floor (agent reflection loop — diff review classifier) export { evaluateRiskFloor, DEFAULT_RISK_THRESHOLD } from './risk-floor.js'; diff --git a/packages/macp/src/types.ts b/packages/macp/src/types.ts index 7e7b0d01..a8418ecc 100644 --- a/packages/macp/src/types.ts +++ b/packages/macp/src/types.ts @@ -1,3 +1,5 @@ +import type { MacpErrorCode } from './errors.js'; + /** Task status values. */ export type TaskStatus = 'pending' | 'running' | 'gated' | 'completed' | 'failed' | 'escalated'; @@ -17,7 +19,17 @@ export type DispatchMode = 'yolo' | 'acp' | 'exec'; export type DependsOnPolicy = 'all' | 'any' | 'all_terminal'; /** Quality gate type. */ -export type GateType = 'mechanical' | 'ai-review' | 'ci-pipeline'; +export type GateType = 'mechanical' | 'ai-review' | 'ci-pipeline' | 'manual'; + +/** + * Typed execution state of a gate — closed set (RI-N2, SDLC-D-035). + * + * Only `passed` means "really executed and green". `simulated` is produced + * exclusively under an explicit simulate opt-in and never satisfies anything. + * `capability_failure` means a required executor/provider/command was absent. + * `waiting` means a manual gate awaits human sign-off (neither pass nor fail). + */ +export type GateStatus = 'passed' | 'failed' | 'simulated' | 'waiting' | 'capability_failure'; /** Gate fail_on mode. */ export type GateFailOn = 'blocker' | 'any'; @@ -67,7 +79,9 @@ export type EventType = | 'task.retry.scheduled' | 'rail.check.started' | 'rail.check.passed' - | 'rail.check.failed'; + | 'rail.check.failed' + | 'rail.check.waiting' + | 'rail.check.simulated'; /** Structured event record. */ export interface MACPEvent { @@ -88,7 +102,14 @@ export interface GateResult { type: string; output: string; timed_out: boolean; + /** Back-compat boolean view — true ONLY when `status === 'passed'`. */ passed: boolean; + /** Typed discriminator — the authoritative gate outcome (RI-N2). */ + status: GateStatus; + /** Typed capability error code, set when `status === 'capability_failure'`. */ + capability_code?: MacpErrorCode; + /** Why a non-executed state (simulated/waiting/capability_failure) was reached. */ + reason?: string; fail_on?: string; blockers?: number; findings?: number; @@ -96,6 +117,22 @@ export interface GateResult { parse_error?: string; } +/** + * Aggregate outcome of `runGates` (RI-N2). + * + * `state` is the typed aggregate: it is `passed` only when every gate really + * executed green. A `simulated` result makes the aggregate `simulated` (never + * `passed`); a `waiting` manual gate keeps the aggregate `waiting`; a missing + * capability makes it `capability_failure`. `allPassed` is exactly + * `state === 'passed'`, so a simulated or waiting result can never satisfy a + * dependency, acceptance criterion, gate, merge, or release check. + */ +export interface RunGatesResult { + allPassed: boolean; + gateResults: GateResult[]; + state: GateStatus; +} + /** Result from a completed task. */ export interface TaskResult { task_id: string;