Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2097379e25 |
@@ -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<string, unknown>) => e.event_type === 'rail.check.failed',
|
|
||||||
);
|
|
||||||
expect(failEvent).toBeDefined();
|
|
||||||
expect(failEvent.message).toContain('Gate failed (');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import { registerMacpCommand } from './cli.js';
|
import { registerMacpCommand } from './cli.js';
|
||||||
|
|
||||||
describe('registerMacpCommand', () => {
|
describe('registerMacpCommand', () => {
|
||||||
@@ -75,3 +78,162 @@ describe('registerMacpCommand', () => {
|
|||||||
expect(topLevel).toContain('events');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+129
-19
@@ -1,5 +1,73 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
|
||||||
import type { Command } from 'commander';
|
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<string, unknown>;
|
||||||
|
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.
|
* Register macp subcommands on an existing Commander program.
|
||||||
* This avoids cross-package Commander version mismatches by using the
|
* 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)',
|
'Filter by task type (coding|deploy|research|review|documentation|infrastructure)',
|
||||||
)
|
)
|
||||||
.action((opts: { status?: string; type?: string }) => {
|
.action((opts: { status?: string; type?: string }) => {
|
||||||
// not yet wired — task persistence layer is not present in @mosaicstack/macp
|
// unimplemented capability — a failure, never a success (RI-N2)
|
||||||
console.log('[macp] tasks list: not yet wired — use macp package programmatically');
|
|
||||||
if (opts.status) {
|
if (opts.status) {
|
||||||
console.log(` status filter: ${opts.status}`);
|
console.log(` status filter: ${opts.status}`);
|
||||||
}
|
}
|
||||||
if (opts.type) {
|
if (opts.type) {
|
||||||
console.log(` type filter: ${opts.type}`);
|
console.log(` type filter: ${opts.type}`);
|
||||||
}
|
}
|
||||||
process.exitCode = 0;
|
notImplemented('tasks list', 'task-persistence', 'use the macp package programmatically');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── submit ──────────────────────────────────────────────────────────────
|
// ─── submit ──────────────────────────────────────────────────────────────
|
||||||
@@ -41,12 +108,11 @@ export function registerMacpCommand(parent: Command): void {
|
|||||||
.command('submit <path>')
|
.command('submit <path>')
|
||||||
.description('Submit a task from a JSON/YAML spec file')
|
.description('Submit a task from a JSON/YAML spec file')
|
||||||
.action((specPath: string) => {
|
.action((specPath: string) => {
|
||||||
// not yet wired — task submission requires a running MACP server
|
// unimplemented capability — a failure, never a success (RI-N2)
|
||||||
console.log('[macp] submit: not yet wired — use macp package programmatically');
|
|
||||||
console.log(` spec path: ${specPath}`);
|
console.log(` spec path: ${specPath}`);
|
||||||
console.log(' task id: (unavailable — no MACP server connected)');
|
console.log(' task id: (unavailable — no MACP server connected)');
|
||||||
console.log(' status: (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 ────────────────────────────────────────────────────────────────
|
// ─── gate ────────────────────────────────────────────────────────────────
|
||||||
@@ -58,16 +124,58 @@ export function registerMacpCommand(parent: Command): void {
|
|||||||
.option('--cwd <path>', 'Working directory for gate execution', process.cwd())
|
.option('--cwd <path>', 'Working directory for gate execution', process.cwd())
|
||||||
.option('--log <path>', 'Path to write gate log output', '/tmp/macp-gate.log')
|
.option('--log <path>', 'Path to write gate log output', '/tmp/macp-gate.log')
|
||||||
.option('--timeout <seconds>', 'Gate timeout in seconds', '60')
|
.option('--timeout <seconds>', 'Gate timeout in seconds', '60')
|
||||||
.action((spec: string, opts: { failOn: string; cwd: string; log: string; timeout: string }) => {
|
.option(
|
||||||
// not yet wired — gate execution requires a task context and event sink
|
'--simulate',
|
||||||
console.log('[macp] gate: not yet wired — use macp package programmatically');
|
'Simulate gates instead of executing them; results are typed simulated and never satisfy a check',
|
||||||
console.log(` spec: ${spec}`);
|
)
|
||||||
console.log(` fail-on: ${opts.failOn}`);
|
.action(
|
||||||
console.log(` cwd: ${opts.cwd}`);
|
(
|
||||||
console.log(` log: ${opts.log}`);
|
spec: string,
|
||||||
console.log(` timeout: ${opts.timeout}s`);
|
opts: { failOn: string; cwd: string; log: string; timeout: string; simulate?: boolean },
|
||||||
process.exitCode = 0;
|
) => {
|
||||||
});
|
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 ──────────────────────────────────────────────────────────────
|
// ─── events ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -79,14 +187,16 @@ export function registerMacpCommand(parent: Command): void {
|
|||||||
.option('--file <path>', 'Path to the MACP events NDJSON file')
|
.option('--file <path>', 'Path to the MACP events NDJSON file')
|
||||||
.option('--follow', 'Follow the file for new events (like tail -f)')
|
.option('--follow', 'Follow the file for new events (like tail -f)')
|
||||||
.action((opts: { file?: string; follow?: boolean }) => {
|
.action((opts: { file?: string; follow?: boolean }) => {
|
||||||
// not yet wired — event streaming requires a live event source
|
// unimplemented capability — a failure, never a success (RI-N2)
|
||||||
console.log('[macp] events tail: not yet wired — use macp package programmatically');
|
|
||||||
if (opts.file) {
|
if (opts.file) {
|
||||||
console.log(` file: ${opts.file}`);
|
console.log(` file: ${opts.file}`);
|
||||||
}
|
}
|
||||||
if (opts.follow) {
|
if (opts.follow) {
|
||||||
console.log(' mode: 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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown>) => 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,20 @@ import { dirname } from 'node:path';
|
|||||||
|
|
||||||
import { emitEvent } from './event-emitter.js';
|
import { emitEvent } from './event-emitter.js';
|
||||||
import { nowISO } 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 {
|
export interface NormalizedGate {
|
||||||
command: string;
|
command: string;
|
||||||
@@ -103,36 +116,91 @@ export function countAIFindings(parsedOutput: unknown): { blockers: number; tota
|
|||||||
return { blockers, total };
|
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(
|
export function runGate(
|
||||||
gate: unknown,
|
gate: unknown,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
logPath: string,
|
logPath: string,
|
||||||
timeoutSec: number,
|
timeoutSec: number,
|
||||||
|
options: RunGateOptions = {},
|
||||||
): GateResult {
|
): GateResult {
|
||||||
const gateEntry = normalizeGate(gate);
|
const gateEntry = normalizeGate(gate);
|
||||||
const gateType = gateEntry.type;
|
const gateType = gateEntry.type;
|
||||||
const command = gateEntry.command;
|
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') {
|
if (gateType === 'ci-pipeline') {
|
||||||
return {
|
return capabilityFailureResult(
|
||||||
command,
|
gateEntry,
|
||||||
exit_code: 0,
|
'MACP_NO_CI_PIPELINE',
|
||||||
type: gateType,
|
`ci-pipeline gate '${gateEntry.command || gateType}' has no CI provider implementation wired — refusing placeholder pass`,
|
||||||
output: 'CI pipeline gate placeholder',
|
);
|
||||||
timed_out: false,
|
|
||||||
passed: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!command) {
|
if (!command) {
|
||||||
return {
|
// A manual gate with no automation waits for human sign-off: not pass, not fail.
|
||||||
command: '',
|
if (gateType === 'manual') {
|
||||||
exit_code: 0,
|
return waitingResult(
|
||||||
type: gateType,
|
gateEntry,
|
||||||
output: '',
|
`manual gate has no automation — waiting for human sign-off (type: ${gateType})`,
|
||||||
timed_out: false,
|
);
|
||||||
passed: true,
|
}
|
||||||
};
|
// 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);
|
const { exitCode, output, timedOut } = runShell(command, cwd, logPath, timeoutSec);
|
||||||
@@ -143,10 +211,12 @@ export function runGate(
|
|||||||
output,
|
output,
|
||||||
timed_out: timedOut,
|
timed_out: timedOut,
|
||||||
passed: false,
|
passed: false,
|
||||||
|
status: 'failed',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (gateType !== 'ai-review') {
|
if (gateType !== 'ai-review') {
|
||||||
result.passed = exitCode === 0;
|
result.passed = exitCode === 0;
|
||||||
|
result.status = result.passed ? 'passed' : 'failed';
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +240,7 @@ export function runGate(
|
|||||||
} else {
|
} else {
|
||||||
result.passed = exitCode === 0 && blockers === 0 && !timedOut && parseError === undefined;
|
result.passed = exitCode === 0 && blockers === 0 && !timedOut && parseError === undefined;
|
||||||
}
|
}
|
||||||
|
result.status = result.passed ? 'passed' : 'failed';
|
||||||
|
|
||||||
result.fail_on = failOn;
|
result.fail_on = failOn;
|
||||||
result.blockers = blockers;
|
result.blockers = blockers;
|
||||||
@@ -191,16 +262,19 @@ export function runGates(
|
|||||||
timeoutSec: number,
|
timeoutSec: number,
|
||||||
eventsPath: string,
|
eventsPath: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
): { allPassed: boolean; gateResults: GateResult[] } {
|
options: RunGateOptions = {},
|
||||||
let allPassed = true;
|
): RunGatesResult {
|
||||||
const gateResults: GateResult[] = [];
|
const gateResults: GateResult[] = [];
|
||||||
|
let hasCapabilityFailure = false;
|
||||||
|
let hasSimulated = false;
|
||||||
|
let hasFailed = false;
|
||||||
|
let hasWaiting = false;
|
||||||
|
|
||||||
for (const gate of gates) {
|
for (const gate of gates) {
|
||||||
const gateEntry = normalizeGate(gate);
|
const gateEntry = normalizeGate(gate);
|
||||||
const gateCmd = gateEntry.command;
|
const gateCmd = gateEntry.command;
|
||||||
if (!gateCmd && gateEntry.type !== 'ci-pipeline') continue;
|
|
||||||
|
|
||||||
const label = gateCmd || gateEntry.type;
|
const label = gateCmd || gateEntry.type;
|
||||||
|
// NOTE: no silent skip — every gate produces a typed result (RI-N2).
|
||||||
emitEvent(
|
emitEvent(
|
||||||
eventsPath,
|
eventsPath,
|
||||||
'rail.check.started',
|
'rail.check.started',
|
||||||
@@ -209,10 +283,10 @@ export function runGates(
|
|||||||
'quality-gate',
|
'quality-gate',
|
||||||
`Running gate: ${label}`,
|
`Running gate: ${label}`,
|
||||||
);
|
);
|
||||||
const result = runGate(gate, cwd, logPath, timeoutSec);
|
const result = runGate(gate, cwd, logPath, timeoutSec, options);
|
||||||
gateResults.push(result);
|
gateResults.push(result);
|
||||||
|
|
||||||
if (result.passed) {
|
if (result.status === 'passed') {
|
||||||
emitEvent(
|
emitEvent(
|
||||||
eventsPath,
|
eventsPath,
|
||||||
'rail.check.passed',
|
'rail.check.passed',
|
||||||
@@ -224,7 +298,46 @@ export function runGates(
|
|||||||
continue;
|
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;
|
let message: string;
|
||||||
if (result.timed_out) {
|
if (result.timed_out) {
|
||||||
message = `Gate timed out after ${timeoutSec}s: ${label}`;
|
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);
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ export type {
|
|||||||
DependsOnPolicy,
|
DependsOnPolicy,
|
||||||
GateType,
|
GateType,
|
||||||
GateFailOn,
|
GateFailOn,
|
||||||
|
GateStatus,
|
||||||
GateEntry,
|
GateEntry,
|
||||||
Task,
|
Task,
|
||||||
EventType,
|
EventType,
|
||||||
MACPEvent,
|
MACPEvent,
|
||||||
GateResult,
|
GateResult,
|
||||||
|
RunGatesResult,
|
||||||
TaskResult,
|
TaskResult,
|
||||||
ProviderMeta,
|
ProviderMeta,
|
||||||
ProviderRegistry,
|
ProviderRegistry,
|
||||||
@@ -18,6 +20,11 @@ export type {
|
|||||||
|
|
||||||
export { CredentialError } from './types.js';
|
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
|
// Credential resolver
|
||||||
export {
|
export {
|
||||||
DEFAULT_CREDENTIALS_DIR,
|
DEFAULT_CREDENTIALS_DIR,
|
||||||
@@ -35,9 +42,16 @@ export {
|
|||||||
export type { ResolveCredentialsOptions } from './credential-resolver.js';
|
export type { ResolveCredentialsOptions } from './credential-resolver.js';
|
||||||
|
|
||||||
// Gate runner
|
// 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)
|
// Risk-floor (agent reflection loop — diff review classifier)
|
||||||
export { evaluateRiskFloor, DEFAULT_RISK_THRESHOLD } from './risk-floor.js';
|
export { evaluateRiskFloor, DEFAULT_RISK_THRESHOLD } from './risk-floor.js';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { MacpErrorCode } from './errors.js';
|
||||||
|
|
||||||
/** Task status values. */
|
/** Task status values. */
|
||||||
export type TaskStatus = 'pending' | 'running' | 'gated' | 'completed' | 'failed' | 'escalated';
|
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';
|
export type DependsOnPolicy = 'all' | 'any' | 'all_terminal';
|
||||||
|
|
||||||
/** Quality gate type. */
|
/** 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. */
|
/** Gate fail_on mode. */
|
||||||
export type GateFailOn = 'blocker' | 'any';
|
export type GateFailOn = 'blocker' | 'any';
|
||||||
@@ -67,7 +79,9 @@ export type EventType =
|
|||||||
| 'task.retry.scheduled'
|
| 'task.retry.scheduled'
|
||||||
| 'rail.check.started'
|
| 'rail.check.started'
|
||||||
| 'rail.check.passed'
|
| 'rail.check.passed'
|
||||||
| 'rail.check.failed';
|
| 'rail.check.failed'
|
||||||
|
| 'rail.check.waiting'
|
||||||
|
| 'rail.check.simulated';
|
||||||
|
|
||||||
/** Structured event record. */
|
/** Structured event record. */
|
||||||
export interface MACPEvent {
|
export interface MACPEvent {
|
||||||
@@ -88,7 +102,14 @@ export interface GateResult {
|
|||||||
type: string;
|
type: string;
|
||||||
output: string;
|
output: string;
|
||||||
timed_out: boolean;
|
timed_out: boolean;
|
||||||
|
/** Back-compat boolean view — true ONLY when `status === 'passed'`. */
|
||||||
passed: boolean;
|
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;
|
fail_on?: string;
|
||||||
blockers?: number;
|
blockers?: number;
|
||||||
findings?: number;
|
findings?: number;
|
||||||
@@ -96,6 +117,22 @@ export interface GateResult {
|
|||||||
parse_error?: string;
|
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. */
|
/** Result from a completed task. */
|
||||||
export interface TaskResult {
|
export interface TaskResult {
|
||||||
task_id: string;
|
task_id: string;
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
# 2026-08-17 — Fleet identity, comms delivery, and the ~/.mosaic tree (continuation record)
|
|
||||||
|
|
||||||
> **Status:** active continuation record | **Owner:** Jason (rulings) / fleet (delivery) | **Created:** 2026-08-17, sb-it-1-dt session with Jarvis (jarvis-brain)
|
|
||||||
> **Audience:** the homelab agents continuing this effort tonight. Read this whole file before acting; it supersedes nothing but preserves structure and decisions that must not be lost.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why this exists
|
|
||||||
|
|
||||||
A session on sb-it-1-dt (2026-08-17) produced three architecture decisions (two awaiting Jason's ruling), one incident postmortem (#1295), interim guardrail edits in the user-owned `~/.mosaic/` contract tree, and one new tool (`ensure-watcher.sh`). The work spans jarvis-brain (P0, not retained) and this repo (the product). **This file is the stack-side anchor so continuation does not depend on jarvis-brain surviving.**
|
|
||||||
|
|
||||||
## 1. The `~/.mosaic` tree model — as-built, preserve this structure
|
|
||||||
|
|
||||||
Three-tree split (this is design intent, not accident; keep it through all framework work):
|
|
||||||
|
|
||||||
| Tree | Owner | Rule |
|
|
||||||
| ------------------- | --------- | ------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `~/.config/mosaic/` | framework | upgrade-managed templates; NEVER user-edited; `mosaic upgrade` may overwrite |
|
|
||||||
| `~/.mosaic/` | user | working contracts, guides, fleet agents; upgrades reconcile with **deny-wins** (user edits never overwritten) |
|
|
||||||
| repo satellites | repos | bootstrapped per-repo `.mosaic/` state |
|
|
||||||
|
|
||||||
As-built inventory of `~/.mosaic` on sb-it-1-dt (2026-08-17):
|
|
||||||
|
|
||||||
- **Contract core:** `CONSTITUTION.md` (L0 law), `AGENTS.md` (dispatcher + guide router + Fleet Comms Watcher requirement), `SOUL.md` (generic base for ALL fleet agents, zero persona — includes the new **Fleet Boundaries** section), `STANDARDS.md` (universal standards — includes new **session identity** + **comms watcher hygiene** sections), `SYSTEM.md` (pure communication contract, byte-identical to jarvis-brain's prompt-testing `sr_opus_5_system_prompt.md`), `USER.md`, `TOOLS.md`.
|
|
||||||
- **`guides/`** — user-owned working copies (E2E-DELIVERY, ORCHESTRATOR(+PROTOCOL,+LEARNINGS), WAKE-DOCTRINE, VAULT-SECRETS, etc.).
|
|
||||||
- **`fleet/agents/`** — the per-agent store (this is MOSAIC-D-002's substrate, already in use):
|
|
||||||
- real agent dirs: `fargo/`, `orchestrator/`, `probe/`, `vision/`, `weekly-update/` — shape: `profile.json` (harness/account/overlay pointer) + `overlay.json` + `SOUL.md` (persona) + `scratch/` `work/` `notes/` subdirs (hygiene rules in root SOUL.md)
|
|
||||||
- `*.env.generated` launch overlays: `luna` `sol` `terra` (carry `MOSAIC_AGENT_NAME`, `_CLASS`, `_RUNTIME`, `_MODEL`, `_REASONING`, `_TOOL_POLICY`) — these are the mosaic-fleet seat launch envs; `inbox.env`, `itops.env` also present
|
|
||||||
- `probe/` is the validated layout proof: auth-bundle symlink chain, per-agent sessions, plugin-store symlink (from 2026-08-07)
|
|
||||||
- **`auth/`, `config/`, `memory/`, `plugins/`, `skills/`, `skills-local/`** — per-tree copies/links for runtime isolation.
|
|
||||||
- Related but outside the tree: watcher units at `~/.config/systemd/user/<agent>-comms-watcher.service`; watcher seen-state at `~/.local/state/comms-watcher-<agent>/`.
|
|
||||||
|
|
||||||
## 2. Decisions register (2026-08-17 session)
|
|
||||||
|
|
||||||
Full strict records live in jarvis-brain `docs/decisions/mosaic-stack/` (render on its dashboard); both are **Pending Jason's ruling**. Summaries so the content survives P0:
|
|
||||||
|
|
||||||
- **MOSAIC-D-001 — SYSTEM.md as canonical harness system prompt.** Static core (Constitution+AGENTS+USER+overlays) in one tracked file; launcher renders dynamic tail (mission/PRD/fleet/persona). Delivery: `--append-system-prompt` (repeatable) for pi/claude; symlinked core file for codex (`$CODEX_HOME/instructions.md`) and opencode (`AGENTS.md`); their dynamic tail via initial prompt (needs live verification). Static-first order is the cache win. `SYSTEM.md` in `~/.mosaic` today is the communication-contract file — D-001's SYSTEM.md is the broader composition; naming to reconcile at implementation.
|
|
||||||
- **MOSAIC-D-002 — per-agent harness homes + mechanical profiles.** Launch with targeted config-dir env vars (e.g. `PI_CODING_AGENT_DIR=~/.mosaic/fleet/agents/<name>/pi`), NOT literal HOME. SOUL.md identity mechanically generated from roster (single writer; kills the hand-copy drift measured in `agents/vision/SOUL.md` on jarvis-brain: declared Jarvis, answered Vision). Composes: SYSTEM core → per-agent SOUL → dynamic tail. `MOSAIC_AGENT_NAME` stays load-bearing for comms.
|
|
||||||
- **Comms delivery tooling belongs in the STACK framework, not jarvis-brain** (decided in discussion; supersedes the interim placement). jarvis-brain keeps only the transport _data_ (`comms/` tree) while it lives. Agents launch from their own repos (terra from `~/src/stack` etc.) — delivery is transport-repo-relative, so this works; but every installed watcher unit's ExecStart currently points into `~/src/jarvis-brain/scripts/` — that dependency is the P0 trap to remove. Migration = move tools + regenerate units, in one step.
|
|
||||||
- **Watcher provisioning is instantiation duty, never running-agent duty.** Interim landed as jarvis-brain `scripts/comms/ensure-watcher.sh` (idempotent ensure + `--status` boot check + interim identity warnings: missing target session, pane `MOSAIC_AGENT_NAME` mismatch via `/proc/<child>/environ`, bare-runtime NOTE). Framework move: fold into `mosaic agent --new` + fleet launch + `mosaic doctor` drift check.
|
|
||||||
- **Prose guardrails landed (interim fences until mechanical fixes):** `~/.mosaic/SOUL.md` Fleet Boundaries (wrong-session tripwire; comms ownership; cross-agent investigation requires tasking) · `STANDARDS.md` session identity + watcher hygiene · `AGENTS.md` Fleet Comms Watcher requirement (P0-interim script path marked transitional).
|
|
||||||
|
|
||||||
## 3. Incident → #1295 (already tracked here)
|
|
||||||
|
|
||||||
`https://git.mosaicstack.dev/mosaicstack/stack/issues/1295` — docs-seat incident: cwd-keyed session files served three lives (dev chat → goals seat → 22 watcher injections into a wedged process); name-based watcher delivery with no identity verification; wedge after pi 0.84.1→0.84.2 update passes every liveness instrument. Proposed fixes enumerated there; provisioning follow-up in comment ID 23027.
|
|
||||||
|
|
||||||
## 4. Open work queue (suggested sequence)
|
|
||||||
|
|
||||||
1. **Comms tooling migration PR** (lane `next`): move `comms-watcher.sh`, `install-watcher.sh`, `ensure-watcher.sh` into the framework tree → deploy `~/.config/mosaic/tools/comms/`; regenerate existing units' ExecStart to framework paths (one-command sweep); keep `COMMS_WATCH_REPO` per-host config (points at a brain checkout until the queue transport lands). Reference: jarvis-brain commit `701c353b1`.
|
|
||||||
2. **Ensure-on-instantiation**: `mosaic agent --new` / fleet launch call ensure semantics; `mosaic doctor` gains the drift check (`--status --all` semantics + `fred`'s hand-written unit as the known drift case; also note daphne/docs/happy/pepper/sanity/tiny/fargo currently have no watcher — cover or consciously exempt).
|
|
||||||
3. **MOSAIC-D-002 implementation** (after ruling): per-agent homes via targeted env vars; roster-generated SOUL.md single-writer; extend the existing `*.env.generated` pattern; launch ledger keeps `config_home` audit.
|
|
||||||
4. **MOSAIC-D-001 implementation** (after ruling): SYSTEM.md sourcing + per-harness delivery + `compose-contract` becomes render-core+tail with drift check; bench cache-ordering before/after (jarvis-brain `domains/software-dev/mosaic-stack/prompt-testing` has the bench).
|
|
||||||
5. **Queue transport + forced separation** (longer term): supersedes watcher path; identity-verification and wedge-detection remain valid regardless of transport.
|
|
||||||
6. **Docs inheritance**: jarvis-brain AGENTS.md's durable comms guidance (E7 pi-glyph delivery gotchas, capture-pane rules, comms protocol) must be inherited into stack docs before P0 retirement.
|
|
||||||
|
|
||||||
## 5. Rules for tonight's agents
|
|
||||||
|
|
||||||
- Lane: **`next`** only; nothing to `main` without Jason (standing ruling).
|
|
||||||
- Attribution caveat #1280: Gitea/git identity from this host may misattribute (issue #1295 showed as created by `@mos-dt-0`); prefer per-invocation `git -c user.name=<seat>` and verify what the remote recorded.
|
|
||||||
- Do not delete `sb-it-1-dt:docs]` (untracked file at repo root) — it is cited fleet-wide as incident evidence.
|
|
||||||
- Edit user contracts in `~/.mosaic/`, never the templates in `~/.config/mosaic/`.
|
|
||||||
- Do not restart other fleet seats unilaterally (goals/scrappy/sanity restart decisions are fred's/Jason's per the docs-seat report).
|
|
||||||
|
|
||||||
## 6. Artifact map
|
|
||||||
|
|
||||||
| Artifact | Where |
|
|
||||||
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| Decision records D-001/D-002 (strict, pending ruling) | jarvis-brain `docs/decisions/mosaic-stack/2026-08-17_mosaic-d-00{1,2}_*.md` |
|
|
||||||
| Incident issue + provisioning comment | stack #1295 + comment 23027 |
|
|
||||||
| ensure-watcher.sh (reference implementation) | jarvis-brain `scripts/comms/ensure-watcher.sh` (commit `701c353b1`) |
|
|
||||||
| Bench for prompt A/B (pi, thinking levels, footer-token semantics) | jarvis-brain `domains/software-dev/mosaic-stack/prompt-testing/` + `docs/reports/2026-08-17-prompt-testing-glm-bench.md` |
|
|
||||||
| Launcher inspection basis | `@mosaicstack/mosaic` 0.0.49 `dist/commands/launch.js` (composeContract / ensureRuntimeConfig / harness-home isolation) |
|
|
||||||
| Guardrail edits | `~/.mosaic/{SOUL,AGENTS,STANDARDS}.md` on sb-it-1-dt (2026-08-17 16:53–17:04) |
|
|
||||||
Reference in New Issue
Block a user