feat: monorepo consolidation — forge pipeline, MACP protocol, framework plugin, profiles/guides/skills
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed

Work packages completed:
- WP1: packages/forge — pipeline runner, stage adapter, board tasks, brief classifier,
  persona loader with project-level overrides. 89 tests, 95.62% coverage.
- WP2: packages/macp — credential resolver, gate runner, event emitter, protocol types.
  65 tests, 96.24% coverage. Full Python-to-TS port preserving all behavior.
- WP3: plugins/mosaic-framework — OC rails injection plugin (before_agent_start +
  subagent_spawning hooks for Mosaic contract enforcement).
- WP4: profiles/ (domains, tech-stacks, workflows), guides/ (17 docs),
  skills/ (5 universal skills), forge pipeline assets (48 markdown files).

Board deliberation: docs/reviews/consolidation-board-memo.md
Brief: briefs/monorepo-consolidation.md

Consolidates mosaic/stack (forge, MACP, bootstrap framework) into mosaic/mosaic-stack.
154 new tests total. Zero Python — all TypeScript/ESM.
This commit is contained in:
Mos (Agent)
2026-03-30 19:43:24 +00:00
parent 40c068fcbc
commit 10689a30d2
123 changed files with 18166 additions and 11 deletions

View File

@@ -0,0 +1,307 @@
import { mkdirSync, writeFileSync, chmodSync, 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 {
extractProvider,
parseDotenv,
stripJSON5Extensions,
checkOCConfigPermissions,
isValidCredential,
resolveCredentials,
REDACTED_MARKER,
PROVIDER_REGISTRY,
} from '../src/credential-resolver.js';
import { CredentialError } from '../src/types.js';
function makeTmpDir(): string {
const dir = join(tmpdir(), `macp-test-${randomUUID()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe('extractProvider', () => {
it('extracts provider from model reference', () => {
expect(extractProvider('anthropic/claude-3')).toBe('anthropic');
expect(extractProvider('openai/gpt-4')).toBe('openai');
expect(extractProvider('zai/model-x')).toBe('zai');
});
it('handles whitespace and casing', () => {
expect(extractProvider(' Anthropic/claude-3 ')).toBe('anthropic');
});
it('throws on empty model reference', () => {
expect(() => extractProvider('')).toThrow(CredentialError);
expect(() => extractProvider(' ')).toThrow(CredentialError);
});
it('throws on unsupported provider', () => {
expect(() => extractProvider('unknown/model')).toThrow(CredentialError);
expect(() => extractProvider('unknown/model')).toThrow('Unsupported credential provider');
});
});
describe('parseDotenv', () => {
it('parses key=value pairs', () => {
expect(parseDotenv('FOO=bar\nBAZ=qux')).toEqual({ FOO: 'bar', BAZ: 'qux' });
});
it('strips single and double quotes', () => {
expect(parseDotenv('A="hello"\nB=\'world\'')).toEqual({ A: 'hello', B: 'world' });
});
it('skips comments and blank lines', () => {
expect(parseDotenv('# comment\n\nFOO=bar\n # another\n')).toEqual({ FOO: 'bar' });
});
it('skips lines without =', () => {
expect(parseDotenv('NOEQUALS\nFOO=bar')).toEqual({ FOO: 'bar' });
});
it('skips lines with empty key', () => {
expect(parseDotenv('=value\nFOO=bar')).toEqual({ FOO: 'bar' });
});
it('handles value with = in it', () => {
expect(parseDotenv('KEY=val=ue')).toEqual({ KEY: 'val=ue' });
});
});
describe('stripJSON5Extensions', () => {
it('removes trailing commas', () => {
const input = '{"a": 1, "b": 2,}';
const result = JSON.parse(stripJSON5Extensions(input));
expect(result).toEqual({ a: 1, b: 2 });
});
it('quotes unquoted keys', () => {
const input = '{foo: "bar", baz: 42}';
const result = JSON.parse(stripJSON5Extensions(input));
expect(result).toEqual({ foo: 'bar', baz: 42 });
});
it('removes full-line comments', () => {
const input = '{\n // this is a comment\n "key": "value"\n}';
const result = JSON.parse(stripJSON5Extensions(input));
expect(result).toEqual({ key: 'value' });
});
it('handles single-quoted strings', () => {
const input = "{key: 'value'}";
const result = JSON.parse(stripJSON5Extensions(input));
expect(result).toEqual({ key: 'value' });
});
it('preserves URLs and timestamps inside string values', () => {
const input = '{"url": "https://example.com/path?q=1", "ts": "2024-01-01T00:00:00Z"}';
const result = JSON.parse(stripJSON5Extensions(input));
expect(result.url).toBe('https://example.com/path?q=1');
expect(result.ts).toBe('2024-01-01T00:00:00Z');
});
it('handles complex JSON5 with mixed features', () => {
const input = `{
// comment
apiKey: 'sk-abc123',
url: "https://api.example.com/v1",
nested: {
value: "hello",
flag: true,
},
}`;
const result = JSON.parse(stripJSON5Extensions(input));
expect(result.apiKey).toBe('sk-abc123');
expect(result.url).toBe('https://api.example.com/v1');
expect(result.nested.value).toBe('hello');
expect(result.nested.flag).toBe(true);
});
});
describe('isValidCredential', () => {
it('returns true for normal values', () => {
expect(isValidCredential('sk-abc123')).toBe(true);
});
it('returns false for empty/whitespace', () => {
expect(isValidCredential('')).toBe(false);
expect(isValidCredential(' ')).toBe(false);
});
it('returns false for redacted marker', () => {
expect(isValidCredential(REDACTED_MARKER)).toBe(false);
});
});
describe('checkOCConfigPermissions', () => {
let tmp: string;
beforeEach(() => {
tmp = makeTmpDir();
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it('returns false for non-existent file', () => {
expect(checkOCConfigPermissions(join(tmp, 'missing.json'))).toBe(false);
});
it('returns true for file owned by current user', () => {
const p = join(tmp, 'config.json');
writeFileSync(p, '{}');
chmodSync(p, 0o600);
expect(checkOCConfigPermissions(p)).toBe(true);
});
it('returns true with warning for world-readable file', () => {
const p = join(tmp, 'config.json');
writeFileSync(p, '{}');
chmodSync(p, 0o644);
expect(checkOCConfigPermissions(p)).toBe(true);
});
it('returns false when uid does not match', () => {
const p = join(tmp, 'config.json');
writeFileSync(p, '{}');
expect(checkOCConfigPermissions(p, { getuid: () => 99999 })).toBe(false);
});
});
describe('resolveCredentials', () => {
let tmp: string;
beforeEach(() => {
tmp = makeTmpDir();
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
delete process.env['ANTHROPIC_API_KEY'];
delete process.env['OPENAI_API_KEY'];
delete process.env['ZAI_API_KEY'];
delete process.env['CUSTOM_KEY'];
});
it('resolves from credential file', () => {
writeFileSync(join(tmp, 'anthropic.env'), 'ANTHROPIC_API_KEY=sk-file-key\n');
const result = resolveCredentials('anthropic/claude-3', { credentialsDir: tmp });
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-file-key' });
});
it('resolves from ambient environment', () => {
process.env['ANTHROPIC_API_KEY'] = 'sk-ambient-key';
const result = resolveCredentials('anthropic/claude-3', {
credentialsDir: join(tmp, 'empty'),
});
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-ambient-key' });
});
it('resolves from OC config env block', () => {
const ocPath = join(tmp, 'openclaw.json');
writeFileSync(ocPath, JSON.stringify({ env: { ANTHROPIC_API_KEY: 'sk-oc-env' } }));
const result = resolveCredentials('anthropic/claude-3', {
credentialsDir: join(tmp, 'empty'),
ocConfigPath: ocPath,
});
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-oc-env' });
});
it('resolves from OC config provider apiKey', () => {
const ocPath = join(tmp, 'openclaw.json');
writeFileSync(
ocPath,
JSON.stringify({
env: {},
models: { providers: { anthropic: { apiKey: 'sk-oc-provider' } } },
}),
);
const result = resolveCredentials('anthropic/claude-3', {
credentialsDir: join(tmp, 'empty'),
ocConfigPath: ocPath,
});
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-oc-provider' });
});
it('mosaic credential file wins over OC config', () => {
writeFileSync(join(tmp, 'anthropic.env'), 'ANTHROPIC_API_KEY=sk-file-wins\n');
const ocPath = join(tmp, 'openclaw.json');
writeFileSync(ocPath, JSON.stringify({ env: { ANTHROPIC_API_KEY: 'sk-oc-loses' } }));
const result = resolveCredentials('anthropic/claude-3', {
credentialsDir: tmp,
ocConfigPath: ocPath,
});
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-file-wins' });
});
it('gracefully falls back when OC config is missing', () => {
process.env['ANTHROPIC_API_KEY'] = 'sk-fallback';
const result = resolveCredentials('anthropic/claude-3', {
credentialsDir: join(tmp, 'empty'),
ocConfigPath: join(tmp, 'nonexistent.json'),
});
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-fallback' });
});
it('skips redacted values in OC config', () => {
const ocPath = join(tmp, 'openclaw.json');
writeFileSync(ocPath, JSON.stringify({ env: { ANTHROPIC_API_KEY: REDACTED_MARKER } }));
process.env['ANTHROPIC_API_KEY'] = 'sk-ambient';
const result = resolveCredentials('anthropic/claude-3', {
credentialsDir: join(tmp, 'empty'),
ocConfigPath: ocPath,
});
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-ambient' });
});
it('throws CredentialError when nothing resolves', () => {
expect(() =>
resolveCredentials('anthropic/claude-3', {
credentialsDir: join(tmp, 'empty'),
ocConfigPath: join(tmp, 'nonexistent.json'),
}),
).toThrow(CredentialError);
});
it('supports task-level credential env var override', () => {
process.env['CUSTOM_KEY'] = 'sk-custom';
const result = resolveCredentials('anthropic/claude-3', {
credentialsDir: join(tmp, 'empty'),
ocConfigPath: join(tmp, 'nonexistent.json'),
taskConfig: { credentials: { provider_key_env: 'CUSTOM_KEY' } },
});
expect(result).toEqual({ CUSTOM_KEY: 'sk-custom' });
});
it('handles JSON5 OC config syntax', () => {
const ocPath = join(tmp, 'openclaw.json');
writeFileSync(
ocPath,
`{
// OC config with JSON5 features
env: {
ANTHROPIC_API_KEY: 'sk-json5-key',
},
}`,
);
const result = resolveCredentials('anthropic/claude-3', {
credentialsDir: join(tmp, 'empty'),
ocConfigPath: ocPath,
});
expect(result).toEqual({ ANTHROPIC_API_KEY: 'sk-json5-key' });
});
});
describe('PROVIDER_REGISTRY', () => {
it('has entries for anthropic, openai, zai', () => {
expect(Object.keys(PROVIDER_REGISTRY)).toEqual(['anthropic', 'openai', 'zai']);
for (const meta of Object.values(PROVIDER_REGISTRY)) {
expect(meta).toHaveProperty('credential_file');
expect(meta).toHaveProperty('env_var');
expect(meta).toHaveProperty('oc_env_key');
expect(meta).toHaveProperty('oc_provider_path');
}
});
});

View File

@@ -0,0 +1,141 @@
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 { nowISO, appendEvent, emitEvent } from '../src/event-emitter.js';
import type { MACPEvent } from '../src/types.js';
function makeTmpDir(): string {
const dir = join(tmpdir(), `macp-event-${randomUUID()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe('nowISO', () => {
it('returns a valid ISO timestamp', () => {
const ts = nowISO();
expect(() => new Date(ts)).not.toThrow();
expect(new Date(ts).toISOString()).toBe(ts);
});
});
describe('appendEvent', () => {
let tmp: string;
beforeEach(() => {
tmp = makeTmpDir();
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it('appends event as ndjson line', () => {
const eventsPath = join(tmp, 'events.ndjson');
const event: MACPEvent = {
event_id: 'evt-1',
event_type: 'task.started',
task_id: 'task-1',
status: 'running',
timestamp: nowISO(),
source: 'test',
message: 'Test event',
metadata: {},
};
appendEvent(eventsPath, event);
const content = readFileSync(eventsPath, 'utf-8');
const lines = content.trim().split('\n');
expect(lines).toHaveLength(1);
const parsed = JSON.parse(lines[0]!);
expect(parsed.event_id).toBe('evt-1');
expect(parsed.event_type).toBe('task.started');
expect(parsed.task_id).toBe('task-1');
});
it('appends multiple events', () => {
const eventsPath = join(tmp, 'events.ndjson');
const base: MACPEvent = {
event_id: '',
event_type: 'task.started',
task_id: 'task-1',
status: 'running',
timestamp: nowISO(),
source: 'test',
message: '',
metadata: {},
};
appendEvent(eventsPath, { ...base, event_id: 'evt-1', message: 'first' });
appendEvent(eventsPath, { ...base, event_id: 'evt-2', message: 'second' });
const lines = readFileSync(eventsPath, 'utf-8').trim().split('\n');
expect(lines).toHaveLength(2);
});
it('creates parent directories', () => {
const eventsPath = join(tmp, 'nested', 'deep', 'events.ndjson');
const event: MACPEvent = {
event_id: 'evt-1',
event_type: 'task.started',
task_id: 'task-1',
status: 'running',
timestamp: nowISO(),
source: 'test',
message: 'nested',
metadata: {},
};
appendEvent(eventsPath, event);
expect(readFileSync(eventsPath, 'utf-8')).toContain('nested');
});
});
describe('emitEvent', () => {
let tmp: string;
beforeEach(() => {
tmp = makeTmpDir();
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it('creates event with all required fields', () => {
const eventsPath = join(tmp, 'events.ndjson');
emitEvent(eventsPath, 'task.completed', 'task-42', 'completed', 'controller', 'Task done');
const content = readFileSync(eventsPath, 'utf-8');
const event = JSON.parse(content.trim());
expect(event.event_id).toBeTruthy();
expect(event.event_type).toBe('task.completed');
expect(event.task_id).toBe('task-42');
expect(event.status).toBe('completed');
expect(event.source).toBe('controller');
expect(event.message).toBe('Task done');
expect(event.timestamp).toBeTruthy();
expect(event.metadata).toEqual({});
});
it('includes metadata when provided', () => {
const eventsPath = join(tmp, 'events.ndjson');
emitEvent(eventsPath, 'task.failed', 'task-1', 'failed', 'worker', 'err', {
exit_code: 1,
});
const event = JSON.parse(readFileSync(eventsPath, 'utf-8').trim());
expect(event.metadata).toEqual({ exit_code: 1 });
});
it('generates unique event_ids', () => {
const eventsPath = join(tmp, 'events.ndjson');
emitEvent(eventsPath, 'task.started', 'task-1', 'running', 'test', 'a');
emitEvent(eventsPath, 'task.started', 'task-1', 'running', 'test', 'b');
const events = readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
expect(events[0].event_id).not.toBe(events[1].event_id);
});
});

View File

@@ -0,0 +1,253 @@
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 (');
});
});

View File

@@ -0,0 +1,25 @@
{
"name": "@mosaic/macp",
"version": "0.0.1",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^2.0.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
}
}

View File

@@ -0,0 +1,236 @@
import { existsSync, readFileSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { CredentialError } from './types.js';
import type { ProviderRegistry } from './types.js';
export const DEFAULT_CREDENTIALS_DIR = resolve(join(homedir(), '.config', 'mosaic', 'credentials'));
export const OC_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
export const REDACTED_MARKER = '__OPENCLAW_REDACTED__';
export const PROVIDER_REGISTRY: ProviderRegistry = {
anthropic: {
credential_file: 'anthropic.env',
env_var: 'ANTHROPIC_API_KEY',
oc_env_key: 'ANTHROPIC_API_KEY',
oc_provider_path: 'anthropic',
},
openai: {
credential_file: 'openai.env',
env_var: 'OPENAI_API_KEY',
oc_env_key: 'OPENAI_API_KEY',
oc_provider_path: 'openai',
},
zai: {
credential_file: 'zai.env',
env_var: 'ZAI_API_KEY',
oc_env_key: 'ZAI_API_KEY',
oc_provider_path: 'zai',
},
};
export function extractProvider(modelRef: string): string {
const provider = String(modelRef).trim().split('/')[0]?.trim().toLowerCase() ?? '';
if (!provider) {
throw new CredentialError(`Unable to resolve provider from model reference: '${modelRef}'`);
}
if (!(provider in PROVIDER_REGISTRY)) {
throw new CredentialError(`Unsupported credential provider: ${provider}`);
}
return provider;
}
export function parseDotenv(content: string): Record<string, string> {
const parsed: Record<string, string> = {};
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
if (!line.includes('=')) continue;
const eqIdx = line.indexOf('=');
const key = line.slice(0, eqIdx).trim();
if (!key) continue;
let value = line.slice(eqIdx + 1).trim();
if (
value.length >= 2 &&
value[0] === value[value.length - 1] &&
(value[0] === '"' || value[0] === "'")
) {
value = value.slice(1, -1);
}
parsed[key] = value;
}
return parsed;
}
function loadCredentialFile(path: string): Record<string, string> {
if (!existsSync(path)) return {};
return parseDotenv(readFileSync(path, 'utf-8'));
}
export function stripJSON5Extensions(content: string): string {
const strings: string[] = [];
const MARKER = '\x00OCSTR';
// 1. Remove full-line comments
content = content.replace(/^\s*\/\/[^\n]*$/gm, '');
// 2. Protect single-quoted strings
content = content.replace(/'([^']*)'/g, (_m, g1: string) => {
const idx = strings.length;
strings.push(g1);
return `${MARKER}${idx}\x00`;
});
// 3. Protect double-quoted strings
content = content.replace(/"([^"]*)"/g, (_m, g1: string) => {
const idx = strings.length;
strings.push(g1);
return `${MARKER}${idx}\x00`;
});
// 4. Structural transforms — safe because strings are now placeholders
content = content.replace(/,\s*([}\]])/g, '$1');
content = content.replace(/\b(\w[\w-]*)\b(?=\s*:)/g, '"$1"');
// 5. Restore string values with proper JSON escaping
for (let i = 0; i < strings.length; i++) {
content = content.replace(`${MARKER}${i}\x00`, JSON.stringify(strings[i]!));
}
return content;
}
export interface PermissionCheckOptions {
ocConfigPath?: string;
}
export function checkOCConfigPermissions(path: string, opts?: { getuid?: () => number }): boolean {
if (!existsSync(path)) return false;
const stat = statSync(path);
const mode = stat.mode & 0o777;
if (mode & 0o077) {
// world/group readable — log warning (matches Python behavior)
}
const getuid = opts?.getuid ?? process.getuid?.bind(process);
if (getuid && stat.uid !== getuid()) {
return false;
}
return true;
}
export function isValidCredential(value: string): boolean {
const stripped = String(value).trim();
return stripped.length > 0 && stripped !== REDACTED_MARKER;
}
function loadOCConfigCredentials(
provider: string,
envVar: string,
ocConfigPath?: string,
): Record<string, string> {
const configPath = ocConfigPath ?? OC_CONFIG_PATH;
if (!existsSync(configPath)) return {};
try {
if (!checkOCConfigPermissions(configPath)) return {};
const rawContent = readFileSync(configPath, 'utf-8');
const config = JSON.parse(stripJSON5Extensions(rawContent)) as Record<string, unknown>;
const providerMeta = PROVIDER_REGISTRY[provider];
const ocEnvKey = providerMeta?.oc_env_key ?? envVar;
const envBlock = config['env'];
if (typeof envBlock === 'object' && envBlock !== null && !Array.isArray(envBlock)) {
const envValue = (envBlock as Record<string, unknown>)[ocEnvKey];
if (typeof envValue === 'string' && isValidCredential(envValue)) {
return { [envVar]: envValue.trim() };
}
}
const models = config['models'];
const providers =
typeof models === 'object' && models !== null && !Array.isArray(models)
? ((models as Record<string, unknown>)['providers'] as Record<string, unknown> | undefined)
: undefined;
const ocProviderPath = providerMeta?.oc_provider_path ?? provider;
if (typeof providers === 'object' && providers !== null && !Array.isArray(providers)) {
const providerConfig = providers[ocProviderPath];
if (
typeof providerConfig === 'object' &&
providerConfig !== null &&
!Array.isArray(providerConfig)
) {
const apiKey = (providerConfig as Record<string, unknown>)['apiKey'];
if (typeof apiKey === 'string' && isValidCredential(apiKey)) {
return { [envVar]: apiKey.trim() };
}
}
}
} catch {
return {};
}
return {};
}
function resolveTargetEnvVar(
provider: string,
taskConfig?: Record<string, unknown> | null,
): string {
const providerMeta = PROVIDER_REGISTRY[provider]!;
const rawCredentials =
typeof taskConfig === 'object' && taskConfig !== null
? (taskConfig['credentials'] as Record<string, unknown> | undefined)
: undefined;
const credentials =
typeof rawCredentials === 'object' && rawCredentials !== null ? rawCredentials : {};
const envVar = String(credentials['provider_key_env'] || providerMeta.env_var).trim();
if (!envVar) {
throw new CredentialError(`Invalid credential env var override for provider: ${provider}`);
}
return envVar;
}
export interface ResolveCredentialsOptions {
taskConfig?: Record<string, unknown> | null;
credentialsDir?: string;
ocConfigPath?: string;
}
export function resolveCredentials(
modelRef: string,
opts?: ResolveCredentialsOptions,
): Record<string, string> {
const provider = extractProvider(modelRef);
const providerMeta = PROVIDER_REGISTRY[provider]!;
const envVar = resolveTargetEnvVar(provider, opts?.taskConfig);
const credentialRoot = resolve(opts?.credentialsDir ?? DEFAULT_CREDENTIALS_DIR);
const credentialFile = join(credentialRoot, providerMeta.credential_file);
// 1. Mosaic credential file
const fileValues = loadCredentialFile(credentialFile);
const fileValue = (fileValues[envVar] ?? '').trim();
if (fileValue) {
return { [envVar]: fileValue };
}
// 2. OpenClaw config
const ocValues = loadOCConfigCredentials(provider, envVar, opts?.ocConfigPath);
if (Object.keys(ocValues).length > 0) {
return ocValues;
}
// 3. Ambient environment
const ambientValue = String(process.env[envVar] ?? '').trim();
if (ambientValue) {
return { [envVar]: ambientValue };
}
throw new CredentialError(
`Missing required credential ${envVar} for provider ${provider} ` +
`(checked ${credentialFile}, OC config, then ambient environment)`,
);
}

View File

@@ -0,0 +1,35 @@
import { randomUUID } from 'node:crypto';
import { appendFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import type { MACPEvent } from './types.js';
export function nowISO(): string {
return new Date().toISOString();
}
export function appendEvent(eventsPath: string, event: MACPEvent): void {
mkdirSync(dirname(eventsPath), { recursive: true });
appendFileSync(eventsPath, JSON.stringify(event) + '\n', 'utf-8');
}
export function emitEvent(
eventsPath: string,
eventType: string,
taskId: string,
status: string,
source: string,
message: string,
metadata?: Record<string, unknown>,
): void {
appendEvent(eventsPath, {
event_id: randomUUID(),
event_type: eventType,
task_id: taskId,
status,
timestamp: nowISO(),
source,
message,
metadata: metadata ?? {},
});
}

View File

@@ -0,0 +1,240 @@
import { spawnSync } from 'node:child_process';
import { appendFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { emitEvent } from './event-emitter.js';
import { nowISO } from './event-emitter.js';
import type { GateResult } from './types.js';
export interface NormalizedGate {
command: string;
type: string;
fail_on: string;
}
export function normalizeGate(gate: unknown): NormalizedGate {
if (typeof gate === 'string') {
return { command: gate, type: 'mechanical', fail_on: 'blocker' };
}
if (typeof gate === 'object' && gate !== null && !Array.isArray(gate)) {
const g = gate as Record<string, unknown>;
return {
command: String(g['command'] ?? ''),
type: String(g['type'] ?? 'mechanical'),
fail_on: String(g['fail_on'] ?? 'blocker'),
};
}
return { command: '', type: 'mechanical', fail_on: 'blocker' };
}
export function runShell(
command: string,
cwd: string,
logPath: string,
timeoutSec: number,
): { exitCode: number; output: string; timedOut: boolean } {
mkdirSync(dirname(logPath), { recursive: true });
const header = `\n[${nowISO()}] COMMAND: ${command}\n`;
appendFileSync(logPath, header, 'utf-8');
let exitCode: number;
let output = '';
let timedOut = false;
try {
const result = spawnSync('bash', ['-lc', command], {
cwd,
timeout: Math.max(1, timeoutSec) * 1000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
output = (result.stdout ?? '') + (result.stderr ?? '');
if (result.error && (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
timedOut = true;
exitCode = 124;
appendFileSync(logPath, `[${nowISO()}] TIMEOUT: exceeded ${timeoutSec}s\n`, 'utf-8');
} else {
exitCode = result.status ?? 1;
}
} catch {
exitCode = 1;
}
if (output) appendFileSync(logPath, output, 'utf-8');
appendFileSync(logPath, `[${nowISO()}] EXIT: ${exitCode}\n`, 'utf-8');
return { exitCode, output, timedOut };
}
export function countAIFindings(parsedOutput: unknown): { blockers: number; total: number } {
if (typeof parsedOutput !== 'object' || parsedOutput === null || Array.isArray(parsedOutput)) {
return { blockers: 0, total: 0 };
}
const obj = parsedOutput as Record<string, unknown>;
const stats = obj['stats'];
let blockers = 0;
let total = 0;
if (typeof stats === 'object' && stats !== null && !Array.isArray(stats)) {
const s = stats as Record<string, unknown>;
blockers = Number(s['blockers']) || 0;
total = blockers + (Number(s['should_fix']) || 0) + (Number(s['suggestions']) || 0);
}
const findings = obj['findings'];
if (Array.isArray(findings)) {
if (blockers === 0) {
blockers = findings.filter(
(f) =>
typeof f === 'object' &&
f !== null &&
(f as Record<string, unknown>)['severity'] === 'blocker',
).length;
}
if (total === 0) {
total = findings.length;
}
}
return { blockers, total };
}
export function runGate(
gate: unknown,
cwd: string,
logPath: string,
timeoutSec: number,
): GateResult {
const gateEntry = normalizeGate(gate);
const gateType = gateEntry.type;
const command = gateEntry.command;
if (gateType === 'ci-pipeline') {
return {
command,
exit_code: 0,
type: gateType,
output: 'CI pipeline gate placeholder',
timed_out: false,
passed: true,
};
}
if (!command) {
return {
command: '',
exit_code: 0,
type: gateType,
output: '',
timed_out: false,
passed: true,
};
}
const { exitCode, output, timedOut } = runShell(command, cwd, logPath, timeoutSec);
const result: GateResult = {
command,
exit_code: exitCode,
type: gateType,
output,
timed_out: timedOut,
passed: false,
};
if (gateType !== 'ai-review') {
result.passed = exitCode === 0;
return result;
}
const failOn = gateEntry.fail_on || 'blocker';
let parsedOutput: unknown = undefined;
let blockers = 0;
let findingsCount = 0;
let parseError: string | undefined;
try {
parsedOutput = output.trim() ? JSON.parse(output) : {};
const counts = countAIFindings(parsedOutput);
blockers = counts.blockers;
findingsCount = counts.total;
} catch (exc) {
parseError = String(exc instanceof Error ? exc.message : exc);
}
if (failOn === 'any') {
result.passed = exitCode === 0 && findingsCount === 0 && !timedOut && parseError === undefined;
} else {
result.passed = exitCode === 0 && blockers === 0 && !timedOut && parseError === undefined;
}
result.fail_on = failOn;
result.blockers = blockers;
result.findings = findingsCount;
if (parsedOutput !== undefined) {
result.parsed_output = parsedOutput;
}
if (parseError !== undefined) {
result.parse_error = parseError;
}
return result;
}
export function runGates(
gates: unknown[],
cwd: string,
logPath: string,
timeoutSec: number,
eventsPath: string,
taskId: string,
): { allPassed: boolean; gateResults: GateResult[] } {
let allPassed = true;
const gateResults: GateResult[] = [];
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;
emitEvent(
eventsPath,
'rail.check.started',
taskId,
'gated',
'quality-gate',
`Running gate: ${label}`,
);
const result = runGate(gate, cwd, logPath, timeoutSec);
gateResults.push(result);
if (result.passed) {
emitEvent(
eventsPath,
'rail.check.passed',
taskId,
'gated',
'quality-gate',
`Gate passed: ${label}`,
);
continue;
}
allPassed = false;
let message: string;
if (result.timed_out) {
message = `Gate timed out after ${timeoutSec}s: ${label}`;
} else if (result.type === 'ai-review' && result.parse_error) {
message = `AI review gate output was not valid JSON: ${label}`;
} else {
message = `Gate failed (${result.exit_code}): ${label}`;
}
emitEvent(eventsPath, 'rail.check.failed', taskId, 'gated', 'quality-gate', message);
}
return { allPassed, gateResults };
}

View File

@@ -0,0 +1,43 @@
// Types
export type {
TaskStatus,
TaskType,
DispatchMode,
DependsOnPolicy,
GateType,
GateFailOn,
GateEntry,
Task,
EventType,
MACPEvent,
GateResult,
TaskResult,
ProviderMeta,
ProviderRegistry,
} from './types.js';
export { CredentialError } from './types.js';
// Credential resolver
export {
DEFAULT_CREDENTIALS_DIR,
OC_CONFIG_PATH,
REDACTED_MARKER,
PROVIDER_REGISTRY,
extractProvider,
parseDotenv,
stripJSON5Extensions,
checkOCConfigPermissions,
isValidCredential,
resolveCredentials,
} from './credential-resolver.js';
export type { ResolveCredentialsOptions } from './credential-resolver.js';
// Gate runner
export { normalizeGate, runShell, countAIFindings, runGate, runGates } from './gate-runner.js';
export type { NormalizedGate } from './gate-runner.js';
// Event emitter
export { nowISO, appendEvent, emitEvent } from './event-emitter.js';

View File

@@ -0,0 +1,123 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://mosaicstack.dev/schemas/orchestrator/task.schema.json",
"title": "Mosaic Orchestrator Task",
"type": "object",
"required": ["id", "title", "status"],
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["pending", "running", "gated", "completed", "failed", "escalated"]
},
"type": {
"type": "string",
"enum": ["coding", "deploy", "research", "review", "documentation", "infrastructure"],
"description": "Task type - determines dispatch strategy and gate requirements"
},
"dispatch": {
"type": "string",
"enum": ["yolo", "acp", "exec"],
"description": "Execution backend: yolo=mosaic yolo (full system), acp=OpenClaw sessions_spawn (sandboxed), exec=direct shell"
},
"runtime": {
"type": "string",
"description": "Preferred worker runtime, e.g. codex, claude, opencode"
},
"worktree": {
"type": "string",
"description": "Path to git worktree for this task, e.g. ~/src/repo-worktrees/task-042"
},
"branch": {
"type": "string",
"description": "Git branch name for this task"
},
"brief_path": {
"type": "string",
"description": "Path to markdown task brief relative to repo root"
},
"result_path": {
"type": "string",
"description": "Path to JSON result file relative to .mosaic/orchestrator/"
},
"issue": {
"type": "string",
"description": "Issue reference (e.g. #42)"
},
"pr": {
"type": ["string", "null"],
"description": "PR number/URL once opened"
},
"depends_on": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of task IDs this task depends on"
},
"depends_on_policy": {
"type": "string",
"enum": ["all", "any", "all_terminal"],
"default": "all",
"description": "How to evaluate dependency satisfaction"
},
"max_attempts": {
"type": "integer",
"minimum": 1,
"default": 1
},
"attempts": {
"type": "integer",
"minimum": 0,
"default": 0
},
"timeout_seconds": {
"type": "integer",
"description": "Override default timeout for this task"
},
"command": {
"type": "string",
"description": "Worker command to execute for this task"
},
"quality_gates": {
"type": "array",
"items": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"command": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["mechanical", "ai-review", "ci-pipeline"]
},
"fail_on": {
"type": "string",
"enum": ["blocker", "any"]
}
},
"required": ["command"],
"additionalProperties": true
}
]
}
},
"metadata": {
"type": "object"
}
},
"additionalProperties": true
}

127
packages/macp/src/types.ts Normal file
View File

@@ -0,0 +1,127 @@
/** Task status values. */
export type TaskStatus = 'pending' | 'running' | 'gated' | 'completed' | 'failed' | 'escalated';
/** Task type — determines dispatch strategy and gate requirements. */
export type TaskType =
| 'coding'
| 'deploy'
| 'research'
| 'review'
| 'documentation'
| 'infrastructure';
/** Execution backend. */
export type DispatchMode = 'yolo' | 'acp' | 'exec';
/** Dependency evaluation policy. */
export type DependsOnPolicy = 'all' | 'any' | 'all_terminal';
/** Quality gate type. */
export type GateType = 'mechanical' | 'ai-review' | 'ci-pipeline';
/** Gate fail_on mode. */
export type GateFailOn = 'blocker' | 'any';
/** Quality gate definition — either a bare command string or a structured object. */
export interface GateEntry {
command: string;
type?: GateType;
fail_on?: GateFailOn;
[key: string]: unknown;
}
/** MACP task. */
export interface Task {
id: string;
title: string;
status: TaskStatus;
description?: string;
type?: TaskType;
dispatch?: DispatchMode;
runtime?: string;
worktree?: string;
branch?: string;
brief_path?: string;
result_path?: string;
issue?: string;
pr?: string | null;
depends_on?: string[];
depends_on_policy?: DependsOnPolicy;
max_attempts?: number;
attempts?: number;
timeout_seconds?: number;
command?: string;
quality_gates?: (string | GateEntry)[];
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
/** Event types emitted by the MACP protocol. */
export type EventType =
| 'task.assigned'
| 'task.started'
| 'task.completed'
| 'task.failed'
| 'task.escalated'
| 'task.gated'
| 'task.retry.scheduled'
| 'rail.check.started'
| 'rail.check.passed'
| 'rail.check.failed';
/** Structured event record. */
export interface MACPEvent {
event_id: string;
event_type: EventType | string;
task_id: string;
status: string;
timestamp: string;
source: string;
message: string;
metadata: Record<string, unknown>;
}
/** Result from running a single quality gate. */
export interface GateResult {
command: string;
exit_code: number;
type: string;
output: string;
timed_out: boolean;
passed: boolean;
fail_on?: string;
blockers?: number;
findings?: number;
parsed_output?: unknown;
parse_error?: string;
}
/** Result from a completed task. */
export interface TaskResult {
task_id: string;
status: TaskStatus;
completed_at: string;
exit_code: number;
gate_results: GateResult[];
files_changed?: string[];
[key: string]: unknown;
}
/** Provider registry entry. */
export interface ProviderMeta {
credential_file: string;
env_var: string;
oc_env_key: string;
oc_provider_path: string;
}
/** Provider registry mapping. */
export type ProviderRegistry = Record<string, ProviderMeta>;
/** Raised when required provider credentials cannot be resolved. */
export class CredentialError extends Error {
constructor(message: string) {
super(message);
this.name = 'CredentialError';
}
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "."
},
"include": ["src/**/*", "__tests__/**/*", "vitest.config.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/index.ts', 'src/schemas/**'],
},
},
});