2 Commits

Author SHA1 Message Date
23469e7b33 feat(wave1): populate @mosaic/types and migrate @mosaic/queue imports
- @mosaic/types: full type definitions extracted from queue, bootstrap, context packages
- @mosaic/queue: type imports now sourced from @mosaic/types via workspace:*
- Task, TaskStatus, TaskPriority, TaskLane, CreateTaskInput, etc. centralised
- Runtime constants (TASK_STATUSES etc.) remain in queue/src/task.ts
2026-03-06 16:43:44 -06:00
727b3defc9 feat(queue): stage queue migration package 2026-03-06 16:33:28 -06:00
83 changed files with 21 additions and 9945 deletions

View File

@@ -1,8 +0,0 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).

View File

@@ -1,11 +0,0 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

View File

@@ -1,7 +0,0 @@
---
"@mosaic/types": minor
"@mosaic/queue": minor
"@mosaic/openclaw-context": minor
---
Initial release of the @mosaic/* monorepo packages.

1
.npmrc
View File

@@ -1,2 +1 @@
@mosaic:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm
//git.mosaicstack.dev/api/packages/mosaic/npm/:_authToken=${GITEA_NPM_TOKEN}

View File

@@ -5,45 +5,26 @@ steps:
- corepack enable
- pnpm install --frozen-lockfile
- name: typecheck
image: node:22-alpine
depends_on: [install]
commands:
- pnpm turbo typecheck
- name: lint
image: node:22-alpine
depends_on: [install]
commands:
- pnpm turbo lint
- name: typecheck
image: node:22-alpine
commands:
- pnpm turbo typecheck
- name: build
image: node:22-alpine
depends_on: [typecheck]
commands:
- pnpm turbo build
- name: test
image: node:22-alpine
depends_on: [build]
commands:
- pnpm turbo test
services:
- name: valkey
image: valkey/valkey:8-alpine
environment:
- ALLOW_EMPTY_PASSWORD=yes
- name: publish
image: node:22-alpine
depends_on: [test]
when:
branch: main
event: push
environment:
GITEA_NPM_TOKEN:
from_secret: gitea_npm_token
commands:
- corepack enable
- pnpm changeset version || true
- pnpm changeset publish
ports: ["6379:6379"]

View File

@@ -1,24 +0,0 @@
{
"name": "@mosaic/coord",
"version": "0.1.0",
"type": "module",
"description": "Mosaic mission coordination — TypeScript rewrite of mosaic coord",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"lint": "echo 'ok'",
"test": "vitest run"
},
"dependencies": {
"@mosaic/queue": "workspace:*",
"@mosaic/types": "workspace:*",
"commander": "^13",
"js-yaml": "^4"
},
"devDependencies": {
"@types/js-yaml": "^4",
"@types/node": "^22",
"typescript": "^5",
"vitest": "^2"
}
}

View File

@@ -1,155 +0,0 @@
import { Command } from 'commander';
import { createMission, loadMission } from './mission.js';
import { runTask, resumeTask } from './runner.js';
import { getMissionStatus } from './status.js';
import type { MissionStatusSummary } from './types.js';
interface InitCommandOptions {
readonly name: string;
readonly project?: string;
readonly prefix?: string;
readonly milestones?: string;
readonly qualityGates?: string;
readonly version?: string;
readonly description?: string;
readonly force?: boolean;
}
interface RunCommandOptions {
readonly project: string;
readonly task: string;
readonly runtime?: 'claude' | 'codex';
readonly print?: boolean;
}
interface StatusCommandOptions {
readonly project: string;
readonly format?: 'json' | 'table';
}
function parseMilestones(value: string | undefined): string[] {
if (value === undefined || value.trim().length === 0) {
return [];
}
return value
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function renderStatusTable(status: MissionStatusSummary): string {
const lines = [
`Mission: ${status.mission.name} (${status.mission.id})`,
`Status: ${status.mission.status}`,
`Project: ${status.mission.projectPath}`,
`Milestones: ${status.milestones.completed}/${status.milestones.total} completed`,
`Tasks: total=${status.tasks.total}, done=${status.tasks.done}, in-progress=${status.tasks.inProgress}, pending=${status.tasks.pending}, blocked=${status.tasks.blocked}, cancelled=${status.tasks.cancelled}`,
`Next task: ${status.nextTaskId ?? '—'}`,
`Active session: ${status.activeSession?.sessionId ?? 'none'}`,
];
return lines.join('\n');
}
export function buildCoordCli(): Command {
const program = new Command();
program
.name('mosaic')
.description('Mosaic CLI')
.exitOverride();
const coord = program.command('coord').description('Mission coordination commands');
coord
.command('init')
.description('Initialize orchestrator mission state')
.requiredOption('--name <name>', 'Mission name')
.option('--project <path>', 'Project path')
.option('--prefix <prefix>', 'Task prefix')
.option('--milestones <comma-separated>', 'Milestone names')
.option('--quality-gates <command>', 'Quality gate command')
.option('--version <semver>', 'Milestone version')
.option('--description <description>', 'Mission description')
.option('--force', 'Overwrite active mission')
.action(async (options: InitCommandOptions) => {
const mission = await createMission({
name: options.name,
projectPath: options.project,
prefix: options.prefix,
milestones: parseMilestones(options.milestones),
qualityGates: options.qualityGates,
version: options.version,
description: options.description,
force: options.force,
});
console.log(
JSON.stringify(
{
ok: true,
missionId: mission.id,
projectPath: mission.projectPath,
},
null,
2,
),
);
});
coord
.command('run')
.description('Run a mission task')
.requiredOption('--project <path>', 'Project path')
.requiredOption('--task <id>', 'Task id')
.option('--runtime <runtime>', 'Runtime (claude|codex)')
.option('--print', 'Print launch command only')
.action(async (options: RunCommandOptions) => {
const mission = await loadMission(options.project);
const run = await runTask(mission, options.task, {
runtime: options.runtime,
mode: options.print === true ? 'print-only' : 'interactive',
});
console.log(JSON.stringify(run, null, 2));
});
coord
.command('resume')
.description('Resume a mission task after stale/dead session lock')
.requiredOption('--project <path>', 'Project path')
.requiredOption('--task <id>', 'Task id')
.option('--runtime <runtime>', 'Runtime (claude|codex)')
.option('--print', 'Print launch command only')
.action(async (options: RunCommandOptions) => {
const mission = await loadMission(options.project);
const run = await resumeTask(mission, options.task, {
runtime: options.runtime,
mode: options.print === true ? 'print-only' : 'interactive',
});
console.log(JSON.stringify(run, null, 2));
});
coord
.command('status')
.description('Show mission status')
.requiredOption('--project <path>', 'Project path')
.option('--format <format>', 'Output format (table|json)', 'table')
.action(async (options: StatusCommandOptions) => {
const mission = await loadMission(options.project);
const status = await getMissionStatus(mission);
if (options.format === 'json') {
console.log(JSON.stringify(status, null, 2));
} else {
console.log(renderStatusTable(status));
}
});
return program;
}
export async function runCoordCli(argv: readonly string[] = process.argv): Promise<void> {
const program = buildCoordCli();
await program.parseAsync(argv);
}

View File

@@ -1,34 +0,0 @@
export {
createMission,
loadMission,
missionFilePath,
saveMission,
} from './mission.js';
export {
parseTasksFile,
updateTaskStatus,
writeTasksFile,
} from './tasks-file.js';
export { runTask, resumeTask } from './runner.js';
export { getMissionStatus, getTaskStatus } from './status.js';
export { buildCoordCli, runCoordCli } from './cli.js';
export type {
CreateMissionOptions,
Mission,
MissionMilestone,
MissionRuntime,
MissionSession,
MissionStatus,
MissionStatusSummary,
MissionTask,
NextTaskCapsule,
RunTaskOptions,
TaskDetail,
TaskRun,
TaskStatus,
} from './types.js';
export {
isMissionStatus,
isTaskStatus,
normalizeTaskStatus,
} from './types.js';

View File

@@ -1,415 +0,0 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { writeTasksFile } from './tasks-file.js';
import type {
CreateMissionOptions,
Mission,
MissionMilestone,
MissionSession,
} from './types.js';
import { isMissionStatus } from './types.js';
const DEFAULT_ORCHESTRATOR_DIR = '.mosaic/orchestrator';
const DEFAULT_MISSION_FILE = 'mission.json';
const DEFAULT_TASKS_FILE = 'docs/TASKS.md';
const DEFAULT_MANIFEST_FILE = 'docs/MISSION-MANIFEST.md';
const DEFAULT_SCRATCHPAD_DIR = 'docs/scratchpads';
const DEFAULT_MILESTONE_VERSION = '0.0.1';
function asRecord(value: unknown): Record<string, unknown> {
if (typeof value === 'object' && value !== null) {
return value as Record<string, unknown>;
}
return {};
}
function readString(
source: Record<string, unknown>,
...keys: readonly string[]
): string | undefined {
for (const key of keys) {
const value = source[key];
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
}
return undefined;
}
function readNumber(
source: Record<string, unknown>,
...keys: readonly string[]
): number | undefined {
for (const key of keys) {
const value = source[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
}
return undefined;
}
function normalizeMilestoneStatus(status: string | undefined): MissionMilestone['status'] {
if (status === 'completed') {
return 'completed';
}
if (status === 'in-progress') {
return 'in-progress';
}
if (status === 'blocked') {
return 'blocked';
}
return 'pending';
}
function normalizeSessionRuntime(runtime: string | undefined): MissionSession['runtime'] {
if (runtime === 'claude' || runtime === 'codex' || runtime === 'unknown') {
return runtime;
}
return 'unknown';
}
function normalizeEndedReason(reason: string | undefined): MissionSession['endedReason'] {
if (
reason === 'completed' ||
reason === 'paused' ||
reason === 'crashed' ||
reason === 'killed' ||
reason === 'unknown'
) {
return reason;
}
return undefined;
}
function normalizeMission(raw: unknown, resolvedProjectPath: string): Mission {
const source = asRecord(raw);
const id = readString(source, 'id', 'mission_id') ?? 'mission';
const name = readString(source, 'name') ?? 'Unnamed Mission';
const statusCandidate = readString(source, 'status') ?? 'inactive';
const status = isMissionStatus(statusCandidate) ? statusCandidate : 'inactive';
const mission: Mission = {
schemaVersion: 1,
id,
name,
description: readString(source, 'description'),
projectPath:
readString(source, 'projectPath', 'project_path') ?? resolvedProjectPath,
createdAt:
readString(source, 'createdAt', 'created_at') ?? new Date().toISOString(),
status,
tasksFile: readString(source, 'tasksFile', 'tasks_file') ?? DEFAULT_TASKS_FILE,
manifestFile:
readString(source, 'manifestFile', 'manifest_file') ?? DEFAULT_MANIFEST_FILE,
scratchpadFile:
readString(source, 'scratchpadFile', 'scratchpad_file') ??
`${DEFAULT_SCRATCHPAD_DIR}/${id}.md`,
orchestratorDir:
readString(source, 'orchestratorDir', 'orchestrator_dir') ??
DEFAULT_ORCHESTRATOR_DIR,
taskPrefix: readString(source, 'taskPrefix', 'task_prefix'),
qualityGates: readString(source, 'qualityGates', 'quality_gates'),
milestoneVersion: readString(source, 'milestoneVersion', 'milestone_version'),
milestones: [],
sessions: [],
};
const milestonesRaw = Array.isArray(source.milestones) ? source.milestones : [];
mission.milestones = milestonesRaw.map((milestoneValue, index) => {
const milestone = asRecord(milestoneValue);
return {
id: readString(milestone, 'id') ?? `phase-${index + 1}`,
name: readString(milestone, 'name') ?? `Phase ${index + 1}`,
status: normalizeMilestoneStatus(readString(milestone, 'status')),
branch: readString(milestone, 'branch'),
issueRef: readString(milestone, 'issueRef', 'issue_ref'),
startedAt: readString(milestone, 'startedAt', 'started_at'),
completedAt: readString(milestone, 'completedAt', 'completed_at'),
};
});
const sessionsRaw = Array.isArray(source.sessions) ? source.sessions : [];
mission.sessions = sessionsRaw.map((sessionValue, index) => {
const session = asRecord(sessionValue);
const fallbackSessionId = `sess-${String(index + 1).padStart(3, '0')}`;
return {
sessionId: readString(session, 'sessionId', 'session_id') ?? fallbackSessionId,
runtime: normalizeSessionRuntime(readString(session, 'runtime')),
pid: readNumber(session, 'pid'),
startedAt:
readString(session, 'startedAt', 'started_at') ?? mission.createdAt,
endedAt: readString(session, 'endedAt', 'ended_at'),
endedReason: normalizeEndedReason(
readString(session, 'endedReason', 'ended_reason'),
),
milestoneId: readString(session, 'milestoneId', 'milestone_id'),
lastTaskId: readString(session, 'lastTaskId', 'last_task_id'),
durationSeconds: readNumber(session, 'durationSeconds', 'duration_seconds'),
};
});
return mission;
}
function missionIdFromName(name: string): string {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-');
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
return `${slug || 'mission'}-${date}`;
}
function toAbsolutePath(basePath: string, targetPath: string): string {
if (path.isAbsolute(targetPath)) {
return targetPath;
}
return path.join(basePath, targetPath);
}
function isNodeErrorWithCode(error: unknown, code: string): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === code
);
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch (error) {
if (isNodeErrorWithCode(error, 'ENOENT')) {
return false;
}
throw error;
}
}
async function writeFileAtomic(filePath: string, content: string): Promise<void> {
const directory = path.dirname(filePath);
await fs.mkdir(directory, { recursive: true });
const tempPath = path.join(
directory,
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`,
);
await fs.writeFile(tempPath, content, 'utf8');
await fs.rename(tempPath, filePath);
}
function renderManifest(mission: Mission): string {
const milestoneRows = mission.milestones
.map((milestone, index) => {
const issue = milestone.issueRef ?? '—';
const branch = milestone.branch ?? '—';
const started = milestone.startedAt ?? '—';
const completed = milestone.completedAt ?? '—';
return `| ${index + 1} | ${milestone.id} | ${milestone.name} | ${milestone.status} | ${branch} | ${issue} | ${started} | ${completed} |`;
})
.join('\n');
const body = [
`# Mission Manifest — ${mission.name}`,
'',
'> Persistent document tracking full mission scope, status, and session history.',
'',
'## Mission',
'',
`**ID:** ${mission.id}`,
`**Statement:** ${mission.description ?? ''}`,
'**Phase:** Intake',
'**Current Milestone:** —',
`**Progress:** 0 / ${mission.milestones.length} milestones`,
`**Status:** ${mission.status}`,
`**Last Updated:** ${new Date().toISOString().replace('T', ' ').replace(/\..+/, ' UTC')}`,
'',
'## Milestones',
'',
'| # | ID | Name | Status | Branch | Issue | Started | Completed |',
'|---|-----|------|--------|--------|-------|---------|-----------|',
milestoneRows,
'',
'## Session History',
'',
'| Session | Runtime | Started | Duration | Ended Reason | Last Task |',
'|---------|---------|---------|----------|--------------|-----------|',
'',
`## Scratchpad\n\nPath: \`${mission.scratchpadFile}\``,
'',
];
return body.join('\n');
}
function renderScratchpad(mission: Mission): string {
return [
`# Mission Scratchpad — ${mission.name}`,
'',
'> Append-only log. NEVER delete entries. NEVER overwrite sections.',
'',
'## Original Mission Prompt',
'',
'```',
'(Paste the mission prompt here on first session)',
'```',
'',
'## Planning Decisions',
'',
'## Session Log',
'',
'| Session | Date | Milestone | Tasks Done | Outcome |',
'|---------|------|-----------|------------|---------|',
'',
'## Open Questions',
'',
'## Corrections',
'',
].join('\n');
}
function buildMissionFromOptions(
options: CreateMissionOptions,
resolvedProjectPath: string,
): Mission {
const id = missionIdFromName(options.name);
const createdAt = new Date().toISOString();
const milestones = (options.milestones ?? []).map((name, index) => {
const cleanName = name.trim();
const milestoneName = cleanName.length > 0 ? cleanName : `Phase ${index + 1}`;
return {
id: `phase-${index + 1}`,
name: milestoneName,
status: 'pending' as const,
branch: milestoneName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, ''),
issueRef: undefined,
startedAt: undefined,
completedAt: undefined,
};
});
return {
schemaVersion: 1,
id,
name: options.name,
description: options.description,
projectPath: resolvedProjectPath,
createdAt,
status: 'active',
tasksFile: DEFAULT_TASKS_FILE,
manifestFile: DEFAULT_MANIFEST_FILE,
scratchpadFile: `${DEFAULT_SCRATCHPAD_DIR}/${id}.md`,
orchestratorDir: DEFAULT_ORCHESTRATOR_DIR,
taskPrefix: options.prefix,
qualityGates: options.qualityGates,
milestoneVersion: options.version ?? DEFAULT_MILESTONE_VERSION,
milestones,
sessions: [],
};
}
export function missionFilePath(projectPath: string, mission?: Mission): string {
const orchestratorDir = mission?.orchestratorDir ?? DEFAULT_ORCHESTRATOR_DIR;
const baseDir = path.isAbsolute(orchestratorDir)
? orchestratorDir
: path.join(projectPath, orchestratorDir);
return path.join(baseDir, DEFAULT_MISSION_FILE);
}
export async function saveMission(mission: Mission): Promise<void> {
const filePath = missionFilePath(mission.projectPath, mission);
const payload = `${JSON.stringify(mission, null, 2)}\n`;
await writeFileAtomic(filePath, payload);
}
export async function createMission(options: CreateMissionOptions): Promise<Mission> {
const name = options.name.trim();
if (name.length === 0) {
throw new Error('Mission name is required');
}
const resolvedProjectPath = path.resolve(options.projectPath ?? process.cwd());
const mission = buildMissionFromOptions({ ...options, name }, resolvedProjectPath);
const missionPath = missionFilePath(resolvedProjectPath, mission);
const hasExistingMission = await fileExists(missionPath);
if (hasExistingMission) {
const existingRaw = await fs.readFile(missionPath, 'utf8');
const existingMission = normalizeMission(JSON.parse(existingRaw), resolvedProjectPath);
const active = existingMission.status === 'active' || existingMission.status === 'paused';
if (active && options.force !== true) {
throw new Error(
`Active mission exists: ${existingMission.name} (${existingMission.status}). Use force to overwrite.`,
);
}
}
await saveMission(mission);
const manifestPath = toAbsolutePath(resolvedProjectPath, mission.manifestFile);
const scratchpadPath = toAbsolutePath(resolvedProjectPath, mission.scratchpadFile);
const tasksPath = toAbsolutePath(resolvedProjectPath, mission.tasksFile);
if (options.force === true || !(await fileExists(manifestPath))) {
await writeFileAtomic(manifestPath, renderManifest(mission));
}
if (!(await fileExists(scratchpadPath))) {
await writeFileAtomic(scratchpadPath, renderScratchpad(mission));
}
if (!(await fileExists(tasksPath))) {
await writeFileAtomic(tasksPath, writeTasksFile([]));
}
return mission;
}
export async function loadMission(projectPath: string): Promise<Mission> {
const resolvedProjectPath = path.resolve(projectPath);
const filePath = missionFilePath(resolvedProjectPath);
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf8');
} catch (error) {
if (isNodeErrorWithCode(error, 'ENOENT')) {
throw new Error(`No mission found at ${filePath}`);
}
throw error;
}
const mission = normalizeMission(JSON.parse(raw), resolvedProjectPath);
if (mission.status === 'inactive') {
throw new Error('Mission exists but is inactive. Re-initialize with mosaic coord init.');
}
return mission;
}

View File

@@ -1,488 +0,0 @@
import { spawn, spawnSync } from 'node:child_process';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { loadMission, saveMission } from './mission.js';
import { parseTasksFile, updateTaskStatus } from './tasks-file.js';
import type {
Mission,
MissionMilestone,
MissionSession,
RunTaskOptions,
TaskRun,
} from './types.js';
const SESSION_LOCK_FILE = 'session.lock';
const NEXT_TASK_FILE = 'next-task.json';
interface SessionLockState {
session_id: string;
runtime: 'claude' | 'codex';
pid: number;
started_at: string;
project_path: string;
milestone_id?: string;
}
function orchestratorDirPath(mission: Mission): string {
if (path.isAbsolute(mission.orchestratorDir)) {
return mission.orchestratorDir;
}
return path.join(mission.projectPath, mission.orchestratorDir);
}
function sessionLockPath(mission: Mission): string {
return path.join(orchestratorDirPath(mission), SESSION_LOCK_FILE);
}
function nextTaskCapsulePath(mission: Mission): string {
return path.join(orchestratorDirPath(mission), NEXT_TASK_FILE);
}
function tasksFilePath(mission: Mission): string {
if (path.isAbsolute(mission.tasksFile)) {
return mission.tasksFile;
}
return path.join(mission.projectPath, mission.tasksFile);
}
function buildSessionId(mission: Mission): string {
return `sess-${String(mission.sessions.length + 1).padStart(3, '0')}`;
}
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function currentMilestone(mission: Mission): MissionMilestone | undefined {
return mission.milestones.find((milestone) => milestone.status === 'in-progress')
?? mission.milestones.find((milestone) => milestone.status === 'pending');
}
async function readTasks(mission: Mission) {
const filePath = tasksFilePath(mission);
try {
const content = await fs.readFile(filePath, 'utf8');
return parseTasksFile(content);
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'ENOENT'
) {
return [];
}
throw error;
}
}
function currentBranch(projectPath: string): string | undefined {
const result = spawnSync('git', ['-C', projectPath, 'branch', '--show-current'], {
encoding: 'utf8',
});
if (result.status !== 0) {
return undefined;
}
const branch = result.stdout.trim();
return branch.length > 0 ? branch : undefined;
}
function percentage(done: number, total: number): number {
if (total === 0) {
return 0;
}
return Math.floor((done / total) * 100);
}
function formatDurationSeconds(totalSeconds: number): string {
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
if (totalSeconds < 3600) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}m ${seconds}s`;
}
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
function buildContinuationPrompt(params: {
mission: Mission;
taskId: string;
runtime: 'claude' | 'codex';
tasksDone: number;
tasksTotal: number;
currentMilestone?: MissionMilestone;
previousSession?: MissionSession;
branch?: string;
}): string {
const {
mission,
taskId,
runtime,
tasksDone,
tasksTotal,
currentMilestone,
previousSession,
branch,
} = params;
const pct = percentage(tasksDone, tasksTotal);
const previousDuration =
previousSession?.durationSeconds !== undefined
? formatDurationSeconds(previousSession.durationSeconds)
: '—';
return [
'## Continuation Mission',
'',
`Continue **${mission.name}** from existing state.`,
'',
'## Setup',
'',
`- **Project:** ${mission.projectPath}`,
`- **State:** ${mission.tasksFile} (${tasksDone}/${tasksTotal} tasks complete)`,
`- **Manifest:** ${mission.manifestFile}`,
`- **Scratchpad:** ${mission.scratchpadFile}`,
'- **Protocol:** ~/.config/mosaic/guides/ORCHESTRATOR.md',
`- **Quality gates:** ${mission.qualityGates ?? '—'}`,
`- **Target runtime:** ${runtime}`,
'',
'## Resume Point',
'',
`- **Current milestone:** ${currentMilestone?.name ?? '—'} (${currentMilestone?.id ?? '—'})`,
`- **Next task:** ${taskId}`,
`- **Progress:** ${tasksDone}/${tasksTotal} (${pct}%)`,
`- **Branch:** ${branch ?? '—'}`,
'',
'## Previous Session Context',
'',
`- **Session:** ${previousSession?.sessionId ?? '—'} (${previousSession?.runtime ?? '—'}, ${previousDuration})`,
`- **Ended:** ${previousSession?.endedReason ?? '—'}`,
`- **Last completed task:** ${previousSession?.lastTaskId ?? '—'}`,
'',
'## Instructions',
'',
'1. Read `~/.config/mosaic/guides/ORCHESTRATOR.md` for full protocol',
`2. Read \`${mission.manifestFile}\` for mission scope and status`,
`3. Read \`${mission.scratchpadFile}\` for session history and decisions`,
`4. Read \`${mission.tasksFile}\` for current task state`,
'5. `git pull --rebase` to sync latest changes',
`6. Launch runtime with \`${runtime} -p\``,
`7. Continue execution from task **${taskId}**`,
'8. Follow Two-Phase Completion Protocol',
`9. You are the SOLE writer of \`${mission.tasksFile}\``,
].join('\n');
}
function resolveLaunchCommand(
runtime: 'claude' | 'codex',
prompt: string,
configuredCommand: string[] | undefined,
): string[] {
if (configuredCommand === undefined || configuredCommand.length === 0) {
return [runtime, '-p', prompt];
}
const withInterpolation = configuredCommand.map((value) =>
value === '{prompt}' ? prompt : value,
);
if (withInterpolation.includes(prompt)) {
return withInterpolation;
}
return [...withInterpolation, prompt];
}
async function writeAtomicJson(filePath: string, payload: unknown): Promise<void> {
const directory = path.dirname(filePath);
await fs.mkdir(directory, { recursive: true });
const tempPath = path.join(
directory,
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`,
);
await fs.writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
await fs.rename(tempPath, filePath);
}
async function readSessionLock(mission: Mission): Promise<SessionLockState | undefined> {
const filePath = sessionLockPath(mission);
try {
const raw = await fs.readFile(filePath, 'utf8');
const data = JSON.parse(raw) as Partial<SessionLockState>;
if (
typeof data.session_id !== 'string' ||
(data.runtime !== 'claude' && data.runtime !== 'codex') ||
typeof data.pid !== 'number' ||
typeof data.started_at !== 'string' ||
typeof data.project_path !== 'string'
) {
return undefined;
}
return {
session_id: data.session_id,
runtime: data.runtime,
pid: data.pid,
started_at: data.started_at,
project_path: data.project_path,
milestone_id: data.milestone_id,
};
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'ENOENT'
) {
return undefined;
}
throw error;
}
}
async function writeSessionLock(
mission: Mission,
lock: SessionLockState,
): Promise<void> {
await writeAtomicJson(sessionLockPath(mission), lock);
}
function markSessionCrashed(
mission: Mission,
sessionId: string,
endedAt: string,
): Mission {
const sessions = mission.sessions.map((session) => {
if (session.sessionId !== sessionId) {
return session;
}
if (session.endedAt !== undefined) {
return session;
}
const startedEpoch = Date.parse(session.startedAt);
const endedEpoch = Date.parse(endedAt);
const durationSeconds =
Number.isFinite(startedEpoch) && Number.isFinite(endedEpoch)
? Math.max(0, Math.floor((endedEpoch - startedEpoch) / 1000))
: undefined;
return {
...session,
endedAt,
endedReason: 'crashed' as const,
durationSeconds,
};
});
return {
...mission,
sessions,
};
}
export async function runTask(
mission: Mission,
taskId: string,
options: RunTaskOptions = {},
): Promise<TaskRun> {
const runtime = options.runtime ?? 'claude';
const mode = options.mode ?? 'interactive';
const freshMission = await loadMission(mission.projectPath);
const tasks = await readTasks(freshMission);
const matches = tasks.filter((task) => task.id === taskId);
if (matches.length === 0) {
throw new Error(`Task not found: ${taskId}`);
}
if (matches.length > 1) {
throw new Error(`Duplicate task IDs found: ${taskId}`);
}
const task = matches[0];
if (task.status === 'done' || task.status === 'cancelled') {
throw new Error(`Task ${taskId} cannot be run from status ${task.status}`);
}
const tasksTotal = tasks.length;
const tasksDone = tasks.filter((candidate) => candidate.status === 'done').length;
const selectedMilestone =
freshMission.milestones.find((milestone) => milestone.id === options.milestoneId)
?? freshMission.milestones.find((milestone) => milestone.id === task.milestone)
?? currentMilestone(freshMission);
const continuationPrompt = buildContinuationPrompt({
mission: freshMission,
taskId,
runtime,
tasksDone,
tasksTotal,
currentMilestone: selectedMilestone,
previousSession: freshMission.sessions.at(-1),
branch: currentBranch(freshMission.projectPath),
});
const launchCommand = resolveLaunchCommand(runtime, continuationPrompt, options.command);
const startedAt = new Date().toISOString();
const sessionId = buildSessionId(freshMission);
const lockFile = sessionLockPath(freshMission);
await writeAtomicJson(nextTaskCapsulePath(freshMission), {
generated_at: startedAt,
runtime,
mission_id: freshMission.id,
mission_name: freshMission.name,
project_path: freshMission.projectPath,
quality_gates: freshMission.qualityGates ?? '',
current_milestone: {
id: selectedMilestone?.id ?? '',
name: selectedMilestone?.name ?? '',
},
next_task: taskId,
progress: {
tasks_done: tasksDone,
tasks_total: tasksTotal,
pct: percentage(tasksDone, tasksTotal),
},
current_branch: currentBranch(freshMission.projectPath) ?? '',
});
if (mode === 'print-only') {
return {
missionId: freshMission.id,
taskId,
sessionId,
runtime,
launchCommand,
startedAt,
lockFile,
};
}
await updateTaskStatus(freshMission, taskId, 'in-progress');
await writeSessionLock(freshMission, {
session_id: sessionId,
runtime,
pid: 0,
started_at: startedAt,
project_path: freshMission.projectPath,
milestone_id: selectedMilestone?.id,
});
const child = spawn(launchCommand[0], launchCommand.slice(1), {
cwd: freshMission.projectPath,
env: {
...process.env,
...(options.env ?? {}),
},
stdio: 'inherit',
});
await new Promise<void>((resolve, reject) => {
child.once('spawn', () => {
resolve();
});
child.once('error', (error) => {
reject(error);
});
});
const pid = child.pid;
if (pid === undefined) {
throw new Error('Failed to start task runtime process (pid missing)');
}
await writeSessionLock(freshMission, {
session_id: sessionId,
runtime,
pid,
started_at: startedAt,
project_path: freshMission.projectPath,
milestone_id: selectedMilestone?.id,
});
const updatedMission: Mission = {
...freshMission,
status: 'active',
sessions: [
...freshMission.sessions,
{
sessionId,
runtime,
pid,
startedAt,
milestoneId: selectedMilestone?.id,
lastTaskId: taskId,
},
],
};
await saveMission(updatedMission);
return {
missionId: updatedMission.id,
taskId,
sessionId,
runtime,
launchCommand,
startedAt,
pid,
lockFile,
};
}
export async function resumeTask(
mission: Mission,
taskId: string,
options: Omit<RunTaskOptions, 'milestoneId'> = {},
): Promise<TaskRun> {
const freshMission = await loadMission(mission.projectPath);
const lock = await readSessionLock(freshMission);
if (lock !== undefined && lock.pid > 0 && isPidAlive(lock.pid)) {
throw new Error(
`Session ${lock.session_id} is still running (PID ${lock.pid}).`,
);
}
let nextMissionState = freshMission;
if (lock !== undefined) {
const endedAt = new Date().toISOString();
nextMissionState = markSessionCrashed(freshMission, lock.session_id, endedAt);
await saveMission(nextMissionState);
await fs.rm(sessionLockPath(nextMissionState), { force: true });
}
return runTask(nextMissionState, taskId, options);
}

View File

@@ -1,183 +0,0 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { loadMission } from './mission.js';
import { parseTasksFile } from './tasks-file.js';
import type {
Mission,
MissionSession,
MissionStatusSummary,
MissionTask,
TaskDetail,
} from './types.js';
const SESSION_LOCK_FILE = 'session.lock';
interface SessionLockState {
session_id?: string;
runtime?: string;
pid?: number;
started_at?: string;
milestone_id?: string;
}
function tasksFilePath(mission: Mission): string {
if (path.isAbsolute(mission.tasksFile)) {
return mission.tasksFile;
}
return path.join(mission.projectPath, mission.tasksFile);
}
function sessionLockPath(mission: Mission): string {
const orchestratorDir = path.isAbsolute(mission.orchestratorDir)
? mission.orchestratorDir
: path.join(mission.projectPath, mission.orchestratorDir);
return path.join(orchestratorDir, SESSION_LOCK_FILE);
}
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function readTasks(mission: Mission): Promise<MissionTask[]> {
try {
const content = await fs.readFile(tasksFilePath(mission), 'utf8');
return parseTasksFile(content);
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'ENOENT'
) {
return [];
}
throw error;
}
}
async function readActiveSession(mission: Mission): Promise<MissionSession | undefined> {
let lockRaw: string;
try {
lockRaw = await fs.readFile(sessionLockPath(mission), 'utf8');
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'ENOENT'
) {
return undefined;
}
throw error;
}
const lock = JSON.parse(lockRaw) as SessionLockState;
if (
typeof lock.session_id !== 'string' ||
(lock.runtime !== 'claude' && lock.runtime !== 'codex') ||
typeof lock.started_at !== 'string'
) {
return undefined;
}
const pid = typeof lock.pid === 'number' ? lock.pid : undefined;
if (pid !== undefined && pid > 0 && !isPidAlive(pid)) {
return undefined;
}
const existingSession = mission.sessions.find(
(session) => session.sessionId === lock.session_id,
);
if (existingSession !== undefined) {
return existingSession;
}
return {
sessionId: lock.session_id,
runtime: lock.runtime,
pid,
startedAt: lock.started_at,
milestoneId: lock.milestone_id,
};
}
export async function getMissionStatus(mission: Mission): Promise<MissionStatusSummary> {
const freshMission = await loadMission(mission.projectPath);
const tasks = await readTasks(freshMission);
const done = tasks.filter((task) => task.status === 'done').length;
const inProgress = tasks.filter((task) => task.status === 'in-progress').length;
const pending = tasks.filter((task) => task.status === 'not-started').length;
const blocked = tasks.filter((task) => task.status === 'blocked').length;
const cancelled = tasks.filter((task) => task.status === 'cancelled').length;
const nextTask = tasks.find((task) => task.status === 'not-started');
const completedMilestones = freshMission.milestones.filter(
(milestone) => milestone.status === 'completed',
).length;
const currentMilestone =
freshMission.milestones.find((milestone) => milestone.status === 'in-progress')
?? freshMission.milestones.find((milestone) => milestone.status === 'pending');
const activeSession = await readActiveSession(freshMission);
return {
mission: {
id: freshMission.id,
name: freshMission.name,
status: freshMission.status,
projectPath: freshMission.projectPath,
},
milestones: {
total: freshMission.milestones.length,
completed: completedMilestones,
current: currentMilestone,
},
tasks: {
total: tasks.length,
done,
inProgress,
pending,
blocked,
cancelled,
},
nextTaskId: nextTask?.id,
activeSession,
};
}
export async function getTaskStatus(
mission: Mission,
taskId: string,
): Promise<TaskDetail> {
const freshMission = await loadMission(mission.projectPath);
const tasks = await readTasks(freshMission);
const matches = tasks.filter((task) => task.id === taskId);
if (matches.length === 0) {
throw new Error(`Task not found: ${taskId}`);
}
if (matches.length > 1) {
throw new Error(`Duplicate task IDs found: ${taskId}`);
}
const summary = await getMissionStatus(freshMission);
return {
missionId: freshMission.id,
task: matches[0],
isNextTask: summary.nextTaskId === taskId,
activeSession: summary.activeSession,
};
}

View File

@@ -1,378 +0,0 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import type { Mission, MissionTask, TaskStatus } from './types.js';
import { normalizeTaskStatus } from './types.js';
const TASKS_LOCK_FILE = '.TASKS.md.lock';
const TASKS_LOCK_STALE_MS = 5 * 60 * 1000;
const TASKS_LOCK_WAIT_MS = 5 * 1000;
const TASKS_LOCK_RETRY_MS = 100;
const DEFAULT_TABLE_HEADER = [
'| id | status | milestone | description | pr | notes |',
'|----|--------|-----------|-------------|----|-------|',
] as const;
const DEFAULT_TASKS_PREAMBLE = [
'# Tasks',
'',
'> Single-writer: orchestrator only. Workers read but never modify.',
'',
...DEFAULT_TABLE_HEADER,
] as const;
interface ParsedTableRow {
readonly lineIndex: number;
readonly cells: string[];
}
interface ParsedTable {
readonly headerLineIndex: number;
readonly separatorLineIndex: number;
readonly headers: string[];
readonly rows: ParsedTableRow[];
readonly idColumn: number;
readonly statusColumn: number;
}
function normalizeHeaderName(input: string): string {
return input.trim().toLowerCase();
}
function splitMarkdownRow(line: string): string[] {
const trimmed = line.trim();
if (!trimmed.startsWith('|')) {
return [];
}
const parts = trimmed.split(/(?<!\\)\|/);
if (parts.length < 3) {
return [];
}
return parts.slice(1, -1).map((part) => part.trim().replace(/\\\|/g, '|'));
}
function isSeparatorRow(cells: readonly string[]): boolean {
return (
cells.length > 0 &&
cells.every((cell) => {
const value = cell.trim();
return /^:?-{3,}:?$/.test(value);
})
);
}
function parseTable(content: string): ParsedTable | undefined {
const lines = content.split(/\r?\n/);
let headerLineIndex = -1;
let separatorLineIndex = -1;
let headers: string[] = [];
for (let index = 0; index < lines.length; index += 1) {
const cells = splitMarkdownRow(lines[index]);
if (cells.length === 0) {
continue;
}
const normalized = cells.map(normalizeHeaderName);
if (!normalized.includes('id') || !normalized.includes('status')) {
continue;
}
if (index + 1 >= lines.length) {
continue;
}
const separatorCells = splitMarkdownRow(lines[index + 1]);
if (!isSeparatorRow(separatorCells)) {
continue;
}
headerLineIndex = index;
separatorLineIndex = index + 1;
headers = normalized;
break;
}
if (headerLineIndex < 0 || separatorLineIndex < 0) {
return undefined;
}
const idColumn = headers.indexOf('id');
const statusColumn = headers.indexOf('status');
if (idColumn < 0 || statusColumn < 0) {
return undefined;
}
const rows: ParsedTableRow[] = [];
let sawData = false;
for (let index = separatorLineIndex + 1; index < lines.length; index += 1) {
const rawLine = lines[index];
const trimmed = rawLine.trim();
if (!trimmed.startsWith('|')) {
if (sawData) {
break;
}
continue;
}
const cells = splitMarkdownRow(rawLine);
if (cells.length === 0) {
if (sawData) {
break;
}
continue;
}
sawData = true;
const normalizedRow = [...cells];
while (normalizedRow.length < headers.length) {
normalizedRow.push('');
}
rows.push({ lineIndex: index, cells: normalizedRow });
}
return {
headerLineIndex,
separatorLineIndex,
headers,
rows,
idColumn,
statusColumn,
};
}
function escapeTableCell(value: string): string {
return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
}
function formatTableRow(cells: readonly string[]): string {
const escaped = cells.map((cell) => escapeTableCell(cell));
return `| ${escaped.join(' | ')} |`;
}
function parseDependencies(raw: string | undefined): string[] {
if (raw === undefined || raw.trim().length === 0) {
return [];
}
return raw
.split(',')
.map((value) => value.trim())
.filter((value) => value.length > 0);
}
function resolveTasksFilePath(mission: Mission): string {
if (path.isAbsolute(mission.tasksFile)) {
return mission.tasksFile;
}
return path.join(mission.projectPath, mission.tasksFile);
}
function isNodeErrorWithCode(error: unknown, code: string): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === code
);
}
async function delay(ms: number): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function acquireLock(lockPath: string): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < TASKS_LOCK_WAIT_MS) {
try {
const handle = await fs.open(lockPath, 'wx');
await handle.writeFile(
JSON.stringify(
{
pid: process.pid,
acquiredAt: new Date().toISOString(),
},
null,
2,
),
);
await handle.close();
return;
} catch (error) {
if (!isNodeErrorWithCode(error, 'EEXIST')) {
throw error;
}
try {
const stats = await fs.stat(lockPath);
if (Date.now() - stats.mtimeMs > TASKS_LOCK_STALE_MS) {
await fs.rm(lockPath, { force: true });
continue;
}
} catch (statError) {
if (!isNodeErrorWithCode(statError, 'ENOENT')) {
throw statError;
}
}
await delay(TASKS_LOCK_RETRY_MS);
}
}
throw new Error(`Timed out acquiring TASKS lock: ${lockPath}`);
}
async function releaseLock(lockPath: string): Promise<void> {
await fs.rm(lockPath, { force: true });
}
async function writeAtomic(filePath: string, content: string): Promise<void> {
const directory = path.dirname(filePath);
const tempPath = path.join(
directory,
`.TASKS.md.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
await fs.writeFile(tempPath, content, 'utf8');
await fs.rename(tempPath, filePath);
}
export function parseTasksFile(content: string): MissionTask[] {
const parsedTable = parseTable(content);
if (parsedTable === undefined) {
return [];
}
const headerToColumn = new Map<string, number>();
parsedTable.headers.forEach((header, index) => {
headerToColumn.set(header, index);
});
const descriptionColumn =
headerToColumn.get('description') ?? headerToColumn.get('title') ?? -1;
const milestoneColumn = headerToColumn.get('milestone') ?? -1;
const prColumn = headerToColumn.get('pr') ?? -1;
const notesColumn = headerToColumn.get('notes') ?? -1;
const assigneeColumn = headerToColumn.get('assignee') ?? -1;
const dependenciesColumn = headerToColumn.get('dependencies') ?? -1;
const tasks: MissionTask[] = [];
for (const row of parsedTable.rows) {
const id = row.cells[parsedTable.idColumn]?.trim();
if (id === undefined || id.length === 0) {
continue;
}
const rawStatusValue = row.cells[parsedTable.statusColumn] ?? '';
const normalized = normalizeTaskStatus(rawStatusValue);
const title = descriptionColumn >= 0 ? row.cells[descriptionColumn] ?? '' : '';
const milestone = milestoneColumn >= 0 ? row.cells[milestoneColumn] ?? '' : '';
const pr = prColumn >= 0 ? row.cells[prColumn] ?? '' : '';
const notes = notesColumn >= 0 ? row.cells[notesColumn] ?? '' : '';
const assignee = assigneeColumn >= 0 ? row.cells[assigneeColumn] ?? '' : '';
const dependenciesRaw =
dependenciesColumn >= 0 ? row.cells[dependenciesColumn] ?? '' : '';
tasks.push({
id,
title,
status: normalized.status,
dependencies: parseDependencies(dependenciesRaw),
milestone: milestone.length > 0 ? milestone : undefined,
pr: pr.length > 0 ? pr : undefined,
notes: notes.length > 0 ? notes : undefined,
assignee: assignee.length > 0 ? assignee : undefined,
rawStatus: normalized.rawStatus,
line: row.lineIndex + 1,
});
}
return tasks;
}
export function writeTasksFile(tasks: MissionTask[]): string {
const lines: string[] = [...DEFAULT_TASKS_PREAMBLE];
for (const task of tasks) {
lines.push(
formatTableRow([
task.id,
task.status,
task.milestone ?? '',
task.title,
task.pr ?? '',
task.notes ?? '',
]),
);
}
return `${lines.join('\n')}\n`;
}
export async function updateTaskStatus(
mission: Mission,
taskId: string,
status: TaskStatus,
): Promise<void> {
const tasksFilePath = resolveTasksFilePath(mission);
const lockPath = path.join(path.dirname(tasksFilePath), TASKS_LOCK_FILE);
await fs.mkdir(path.dirname(tasksFilePath), { recursive: true });
await acquireLock(lockPath);
try {
let content: string;
try {
content = await fs.readFile(tasksFilePath, 'utf8');
} catch (error) {
if (isNodeErrorWithCode(error, 'ENOENT')) {
throw new Error(`TASKS file not found: ${tasksFilePath}`);
}
throw error;
}
const table = parseTable(content);
if (table === undefined) {
throw new Error(`Could not parse TASKS table in ${tasksFilePath}`);
}
const matchingRows = table.rows.filter((row) => {
const rowTaskId = row.cells[table.idColumn]?.trim();
return rowTaskId === taskId;
});
if (matchingRows.length === 0) {
throw new Error(`Task not found in TASKS.md: ${taskId}`);
}
if (matchingRows.length > 1) {
throw new Error(`Duplicate task IDs found in TASKS.md: ${taskId}`);
}
const targetRow = matchingRows[0];
const updatedCells = [...targetRow.cells];
updatedCells[table.statusColumn] = status;
const lines = content.split(/\r?\n/);
lines[targetRow.lineIndex] = formatTableRow(updatedCells);
const updatedContent = `${lines.join('\n').replace(/\n+$/, '')}\n`;
await writeAtomic(tasksFilePath, updatedContent);
} finally {
await releaseLock(lockPath);
}
}

View File

@@ -1,194 +0,0 @@
export type TaskStatus =
| 'not-started'
| 'in-progress'
| 'done'
| 'blocked'
| 'cancelled';
export type MissionStatus = 'active' | 'paused' | 'completed' | 'inactive';
export type MissionRuntime = 'claude' | 'codex' | 'unknown';
export interface MissionMilestone {
id: string;
name: string;
status: 'pending' | 'in-progress' | 'completed' | 'blocked';
branch?: string;
issueRef?: string;
startedAt?: string;
completedAt?: string;
}
export interface MissionSession {
sessionId: string;
runtime: MissionRuntime;
pid?: number;
startedAt: string;
endedAt?: string;
endedReason?: 'completed' | 'paused' | 'crashed' | 'killed' | 'unknown';
milestoneId?: string;
lastTaskId?: string;
durationSeconds?: number;
}
export interface Mission {
schemaVersion: 1;
id: string;
name: string;
description?: string;
projectPath: string;
createdAt: string;
status: MissionStatus;
tasksFile: string;
manifestFile: string;
scratchpadFile: string;
orchestratorDir: string;
taskPrefix?: string;
qualityGates?: string;
milestoneVersion?: string;
milestones: MissionMilestone[];
sessions: MissionSession[];
}
export interface MissionTask {
id: string;
title: string;
status: TaskStatus;
assignee?: string;
dependencies: string[];
milestone?: string;
pr?: string;
notes?: string;
rawStatus?: string;
line?: number;
}
export interface TaskRun {
missionId: string;
taskId: string;
sessionId: string;
runtime: 'claude' | 'codex';
launchCommand: string[];
startedAt: string;
pid?: number;
lockFile: string;
}
export interface MissionStatusSummary {
mission: Pick<Mission, 'id' | 'name' | 'status' | 'projectPath'>;
milestones: {
total: number;
completed: number;
current?: MissionMilestone;
};
tasks: {
total: number;
done: number;
inProgress: number;
pending: number;
blocked: number;
cancelled: number;
};
nextTaskId?: string;
activeSession?: MissionSession;
}
export interface TaskDetail {
missionId: string;
task: MissionTask;
isNextTask: boolean;
activeSession?: MissionSession;
}
export interface CreateMissionOptions {
name: string;
projectPath?: string;
prefix?: string;
milestones?: string[];
qualityGates?: string;
version?: string;
description?: string;
force?: boolean;
}
export interface RunTaskOptions {
runtime?: 'claude' | 'codex';
mode?: 'interactive' | 'print-only';
milestoneId?: string;
launchStrategy?: 'subprocess' | 'spawn-adapter';
env?: Record<string, string>;
command?: string[];
}
export interface NextTaskCapsule {
generatedAt: string;
runtime: 'claude' | 'codex';
missionId: string;
missionName: string;
projectPath: string;
qualityGates?: string;
currentMilestone: {
id?: string;
name?: string;
};
nextTask: string;
progress: {
tasksDone: number;
tasksTotal: number;
pct: number;
};
currentBranch?: string;
}
const LEGACY_TASK_STATUS: Readonly<Record<string, TaskStatus>> = {
'not-started': 'not-started',
pending: 'not-started',
todo: 'not-started',
'in-progress': 'in-progress',
in_progress: 'in-progress',
done: 'done',
completed: 'done',
blocked: 'blocked',
failed: 'blocked',
cancelled: 'cancelled',
};
export function normalizeTaskStatus(input: string): {
status: TaskStatus;
rawStatus?: string;
} {
const raw = input.trim().toLowerCase();
if (raw.length === 0) {
return { status: 'not-started' };
}
const normalized = LEGACY_TASK_STATUS[raw];
if (normalized === undefined) {
return { status: 'not-started', rawStatus: raw };
}
if (raw !== normalized) {
return { status: normalized, rawStatus: raw };
}
return { status: normalized };
}
export function isMissionStatus(value: string): value is MissionStatus {
return (
value === 'active' ||
value === 'paused' ||
value === 'completed' ||
value === 'inactive'
);
}
export function isTaskStatus(value: string): value is TaskStatus {
return (
value === 'not-started' ||
value === 'in-progress' ||
value === 'done' ||
value === 'blocked' ||
value === 'cancelled'
);
}

View File

@@ -1,64 +0,0 @@
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { createMission, loadMission, missionFilePath } from '../src/mission.js';
describe('mission lifecycle', () => {
it('creates and loads mission state files', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'coord-mission-'));
try {
const mission = await createMission({
name: 'Wave 3 Mission',
projectPath: projectDir,
milestones: ['Phase One', 'Phase Two'],
qualityGates: 'pnpm lint && pnpm typecheck && pnpm test',
description: 'Wave 3 implementation',
});
expect(mission.id).toMatch(/^wave-3-mission-\d{8}$/);
expect(mission.status).toBe('active');
expect(mission.milestones).toHaveLength(2);
await expect(fs.stat(missionFilePath(projectDir, mission))).resolves.toBeDefined();
await expect(fs.stat(path.join(projectDir, 'docs/TASKS.md'))).resolves.toBeDefined();
await expect(
fs.stat(path.join(projectDir, '.mosaic/orchestrator/mission.json')),
).resolves.toBeDefined();
const loaded = await loadMission(projectDir);
expect(loaded.id).toBe(mission.id);
expect(loaded.name).toBe('Wave 3 Mission');
expect(loaded.qualityGates).toBe('pnpm lint && pnpm typecheck && pnpm test');
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
it('rejects inactive missions on load', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'coord-mission-inactive-'));
try {
const mission = await createMission({
name: 'Inactive Mission',
projectPath: projectDir,
});
const missionPath = missionFilePath(projectDir, mission);
const payload = JSON.parse(await fs.readFile(missionPath, 'utf8')) as {
status: string;
};
payload.status = 'inactive';
await fs.writeFile(missionPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
await expect(loadMission(projectDir)).rejects.toThrow(
'Mission exists but is inactive',
);
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
});

View File

@@ -1,74 +0,0 @@
import { describe, expect, it } from 'vitest';
import { parseTasksFile, writeTasksFile } from '../src/tasks-file.js';
import type { MissionTask } from '../src/types.js';
describe('parseTasksFile', () => {
it('normalizes legacy statuses from TASKS.md', () => {
const content = [
'# Tasks — Demo',
'',
'| id | status | milestone | description | pr | notes |',
'|----|--------|-----------|-------------|----|-------|',
'| T-1 | pending | phase-1 | First task | #10 | note a |',
'| T-2 | completed | phase-1 | Second task | #11 | note b |',
'| T-3 | in_progress | phase-2 | Third task | | |',
'| T-4 | failed | phase-2 | Fourth task | | |',
'',
'trailing text ignored',
].join('\n');
const tasks = parseTasksFile(content);
expect(tasks).toHaveLength(4);
expect(tasks.map((task) => task.status)).toEqual([
'not-started',
'done',
'in-progress',
'blocked',
]);
expect(tasks.map((task) => task.rawStatus)).toEqual([
'pending',
'completed',
'in_progress',
'failed',
]);
expect(tasks[0]?.line).toBe(5);
});
});
describe('writeTasksFile', () => {
it('round-trips parse/write output', () => {
const tasks: MissionTask[] = [
{
id: 'W3-001',
title: 'Implement parser',
status: 'not-started',
milestone: 'phase-1',
pr: '#20',
notes: 'pending',
dependencies: [],
},
{
id: 'W3-002',
title: 'Implement runner',
status: 'in-progress',
milestone: 'phase-2',
notes: 'active',
dependencies: [],
},
];
const content = writeTasksFile(tasks);
const reparsed = parseTasksFile(content);
expect(reparsed).toHaveLength(2);
expect(reparsed.map((task) => task.id)).toEqual(['W3-001', 'W3-002']);
expect(reparsed.map((task) => task.status)).toEqual([
'not-started',
'in-progress',
]);
expect(reparsed[0]?.title).toBe('Implement parser');
expect(reparsed[1]?.milestone).toBe('phase-2');
});
});

View File

@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}

View File

@@ -1,340 +0,0 @@
# Mosaic Agent Framework
Universal agent standards layer for Claude Code, Codex, and OpenCode.
One config, every runtime, same standards.
> **This repository is a generic framework baseline.** No personal data, credentials, user-specific preferences, or machine-specific paths should be committed. All personalization happens at install time via `mosaic init` or by editing files in `~/.config/mosaic/` after installation.
## Quick Install
### Mac / Linux
```bash
curl -sL https://git.mosaicstack.dev/mosaic/bootstrap/raw/branch/main/remote-install.sh | sh
```
### Windows (PowerShell)
```powershell
irm https://git.mosaicstack.dev/mosaic/bootstrap/raw/branch/main/remote-install.ps1 | iex
```
### From Source (any platform)
```bash
git clone https://git.mosaicstack.dev/mosaic/bootstrap.git ~/src/mosaic-bootstrap
cd ~/src/mosaic-bootstrap && bash install.sh
```
If Node.js 18+ is available, the remote installer automatically uses the TypeScript wizard instead of the bash installer for a richer setup experience.
The installer will:
- Install the framework to `~/.config/mosaic/`
- Add `~/.config/mosaic/bin` to your PATH
- Sync runtime adapters and skills
- Install and configure sequential-thinking MCP (hard requirement)
- Run a health audit
- Detect existing installs and prompt to keep or overwrite local files
- Prompt you to run `mosaic init` to set up your agent identity
## First Run
After install, open a new terminal (or `source ~/.bashrc`) and run:
```bash
mosaic init
```
If Node.js 18+ is installed, this launches an interactive wizard with two modes:
- **Quick Start** (~2 min): agent name + communication style, sensible defaults for everything else
- **Advanced**: full customization of identity, user profile, tools, runtimes, and skills
The wizard configures three files loaded into every agent session:
- `SOUL.md` — agent identity contract (name, style, guardrails)
- `USER.md` — your user profile (name, timezone, accessibility, preferences)
- `TOOLS.md` — machine-level tool reference (git providers, credentials, CLI patterns)
It also detects installed runtimes (Claude, Codex, OpenCode), configures sequential-thinking MCP, and offers curated skill selection from 8 categories.
### Non-Interactive Mode
For CI or scripted installs:
```bash
mosaic init --non-interactive --name Jarvis --style direct --user-name Jason --timezone America/Chicago
```
All flags: `--name`, `--role`, `--style`, `--user-name`, `--pronouns`, `--timezone`, `--mosaic-home`, `--source-dir`.
### Legacy Fallback
If Node.js is unavailable, `mosaic init` falls back to the bash-based `mosaic-init` script.
## Launching Agent Sessions
```bash
mosaic claude # Launch Claude Code with full Mosaic injection
mosaic codex # Launch Codex with full Mosaic injection
mosaic opencode # Launch OpenCode with full Mosaic injection
```
The launcher:
1. Verifies `~/.config/mosaic` exists
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
3. Injects `AGENTS.md` into the runtime
4. Forwards all arguments to the runtime CLI
You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtime adapters will tell the agent to read `~/.config/mosaic/AGENTS.md`.
## Architecture
```
~/.config/mosaic/
├── AGENTS.md ← THE source of truth (all standards, all runtimes)
├── SOUL.md ← Agent identity (generated by mosaic init)
├── USER.md ← User profile and accessibility (generated by mosaic init)
├── TOOLS.md ← Machine-level tool reference (generated by mosaic init)
├── STANDARDS.md ← Machine-wide standards
├── guides/E2E-DELIVERY.md ← Mandatory E2E software delivery procedure
├── guides/PRD.md ← Mandatory PRD requirements gate before coding
├── guides/DOCUMENTATION.md ← Mandatory documentation standard and gates
├── bin/ ← CLI tools (mosaic, mosaic-init, mosaic-doctor, etc.)
├── dist/ ← Bundled wizard (mosaic-wizard.mjs)
├── guides/ ← Operational guides
├── tools/ ← Tool suites: git, portainer, authentik, coolify, codex, etc.
├── runtime/ ← Runtime adapters + runtime-specific references
│ ├── claude/CLAUDE.md
│ ├── claude/RUNTIME.md
│ ├── opencode/AGENTS.md
│ ├── opencode/RUNTIME.md
│ ├── codex/instructions.md
│ ├── codex/RUNTIME.md
│ └── mcp/SEQUENTIAL-THINKING.json
├── skills/ ← Universal skills (synced from mosaic/agent-skills)
├── skills-local/ ← Local cross-runtime skills
└── templates/ ← SOUL.md template, project templates
```
### How AGENTS.md Gets Loaded
| Launch method | Injection mechanism |
|--------------|-------------------|
| `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) |
| `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch |
| `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch |
| `claude` (direct) | `~/.claude/CLAUDE.md` thin pointer → load AGENTS + runtime reference |
| `codex` (direct) | `~/.codex/instructions.md` thin pointer → load AGENTS + runtime reference |
| `opencode` (direct) | `~/.config/opencode/AGENTS.md` thin pointer → load AGENTS + runtime reference |
Mosaic `AGENTS.md` enforces loading `guides/E2E-DELIVERY.md` before execution and
requires `guides/PRD.md` before coding and `guides/DOCUMENTATION.md` for code/API/auth/infra documentation gates.
## Management Commands
```bash
mosaic help # Show all commands
mosaic init # Interactive wizard (or legacy init)
mosaic doctor # Health audit — detect drift and missing files
mosaic sync # Sync skills from canonical source
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
mosaic upgrade check # Check release upgrade status (no changes)
mosaic upgrade # Upgrade installed Mosaic release (keeps SOUL.md by default)
mosaic upgrade --dry-run # Preview release upgrade without changes
mosaic upgrade --ref main # Upgrade from a specific branch/tag/commit ref
mosaic upgrade --overwrite # Upgrade release and overwrite local files
mosaic upgrade project ... # Project file cleanup mode (see below)
```
## Upgrading Mosaic Release
Upgrade the installed framework in place:
```bash
# Default (safe): keep local SOUL.md, USER.md, TOOLS.md + memory
mosaic upgrade
# Check current/target release info without changing files
mosaic upgrade check
# Non-interactive
mosaic upgrade --yes
# Pull a specific ref
mosaic upgrade --ref main
# Force full overwrite (fresh install semantics)
mosaic upgrade --overwrite --yes
```
`mosaic upgrade` re-runs the remote installer and passes install mode controls (`keep`/`overwrite`).
This is the manual upgrade path today and is suitable for future app-driven update checks.
## Upgrading Projects
After centralizing AGENTS.md and SOUL.md, existing projects may have stale files:
```bash
# Preview what would change across all projects
mosaic upgrade project --all --dry-run
# Apply to all projects
mosaic upgrade project --all
# Apply to a specific project
mosaic upgrade project ~/src/my-project
```
Backward compatibility is preserved for historical usage:
```bash
mosaic upgrade --all # still routes to project-upgrade
mosaic upgrade ~/src/my-repo # still routes to project-upgrade
```
What it does per project:
| File | Action |
|------|--------|
| `SOUL.md` | Removed — now global at `~/.config/mosaic/SOUL.md` |
| `CLAUDE.md` | Replaced with thin pointer to global AGENTS.md |
| `AGENTS.md` | Stale load-order sections stripped; project content preserved |
Backups (`.mosaic-bak`) are created before any modification.
## Universal Skills
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`, then links each skill into runtime directories (`~/.claude/skills`, `~/.codex/skills`, `~/.config/opencode/skills`).
```bash
mosaic sync # Full sync (clone + link)
~/.config/mosaic/bin/mosaic-sync-skills --link-only # Re-link only
```
## Runtime Compatibility
The installer pushes thin runtime adapters as regular files (not symlinks):
- `~/.claude/CLAUDE.md` — pointer to `~/.config/mosaic/AGENTS.md`
- `~/.claude/settings.json`, `hooks-config.json`, `context7-integration.md`
- `~/.config/opencode/AGENTS.md` — pointer to `~/.config/mosaic/AGENTS.md`
- `~/.codex/instructions.md` — pointer to `~/.config/mosaic/AGENTS.md`
- `~/.claude/settings.json`, `~/.codex/config.toml`, and `~/.config/opencode/config.json` include sequential-thinking MCP config
Re-sync manually:
```bash
~/.config/mosaic/bin/mosaic-link-runtime-assets
```
## MCP Registration
### How MCPs Are Configured in Claude Code
**MCPs must be registered via `claude mcp add` — not by hand-editing `~/.claude/settings.json`.**
`settings.json` controls hooks, model, plugins, and allowed commands. The `mcpServers` key in
`settings.json` is silently ignored by Claude Code's MCP loader. The correct file is `~/.claude.json`,
which is managed by the `claude mcp` CLI.
```bash
# Register a stdio MCP (user scope = all projects, persists across sessions)
claude mcp add --scope user <name> -- npx -y <package>
# Register an HTTP MCP (e.g. OpenBrain)
claude mcp add --scope user --transport http <name> <url> \
--header "Authorization: Bearer <token>"
# List registered MCPs
claude mcp list
```
**Scope options:**
- `--scope user` — writes to `~/.claude.json`, available in all projects (recommended for shared tools)
- `--scope project` — writes to `.claude/settings.json` in the project root, committed to the repo
- `--scope local` — default, machine-local only, not committed
**Transport for HTTP MCPs must be `http`** — not `sse`. `type: "sse"` is a deprecated protocol
that silently fails to connect against FastMCP streamable HTTP servers.
### sequential-thinking MCP (Hard Requirement)
sequential-thinking MCP is required for Mosaic Stack. The installer registers it automatically.
To verify or re-register manually:
```bash
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check
```
### OpenBrain Semantic Memory (Recommended)
OpenBrain is the shared cross-agent memory layer. Register once per machine:
```bash
claude mcp add --scope user --transport http openbrain https://your-openbrain-host/mcp \
--header "Authorization: Bearer YOUR_TOKEN"
```
See [mosaic/openbrain](https://git.mosaicstack.dev/mosaic/openbrain) for setup and API docs.
## Bootstrap Any Repo
Attach any repository to the Mosaic standards layer:
```bash
mosaic bootstrap /path/to/repo
```
This creates `.mosaic/`, `scripts/agent/`, and an `AGENTS.md` if missing.
## Quality Rails
Apply and verify quality templates:
```bash
~/.config/mosaic/bin/mosaic-quality-apply --template typescript-node --target /path/to/repo
~/.config/mosaic/bin/mosaic-quality-verify --target /path/to/repo
```
Templates: `typescript-node`, `typescript-nextjs`, `monorepo`
## Health Audit
```bash
mosaic doctor # Standard audit
~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
```
## Wizard Development
The installation wizard is a TypeScript project in the root of this repo.
```bash
pnpm install # Install dependencies
pnpm dev # Run wizard from source (tsx)
pnpm build # Bundle to dist/mosaic-wizard.mjs
pnpm test # Run tests (30 tests, vitest)
pnpm typecheck # TypeScript type checking
```
The wizard uses `@clack/prompts` for the interactive TUI and supports `--non-interactive` mode via `HeadlessPrompter` for CI and scripted installs. The bundled output (`dist/mosaic-wizard.mjs`) is committed to the repo so installs work without `node_modules`.
## Re-installing / Updating
Pull the latest and re-run the installer:
```bash
cd ~/src/mosaic-bootstrap && git pull && bash install.sh
```
If an existing install is detected, the installer prompts for:
- `keep` (recommended): preserve local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/`
- `overwrite`: replace everything in `~/.config/mosaic`
Or use the one-liner again — it always pulls the latest:
```bash
curl -sL https://git.mosaicstack.dev/mosaic/bootstrap/raw/branch/main/remote-install.sh | sh
```

View File

@@ -1,39 +0,0 @@
{
"name": "@mosaic/mosaic",
"version": "0.1.0",
"type": "module",
"description": "Mosaic installation wizard and meta-package entry point",
"bin": {
"mosaic-wizard": "./dist/mosaic-wizard.mjs"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": ["dist"],
"scripts": {
"build": "tsdown",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"test": "vitest run",
"dev": "tsx src/index.ts"
},
"dependencies": {
"@clack/prompts": "^0.9",
"commander": "^13",
"picocolors": "^1.1",
"yaml": "^2.7",
"zod": "^3.24"
},
"devDependencies": {
"@types/node": "^22",
"tsdown": "^0.12",
"tsx": "^4",
"typescript": "^5",
"vitest": "^2"
}
}

View File

@@ -1,26 +0,0 @@
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
import { FileConfigAdapter } from './file-adapter.js';
/**
* ConfigService interface — abstracts config read/write operations.
* Currently backed by FileConfigAdapter (writes .md files from templates).
* Designed for future swap to SqliteConfigAdapter or PostgresConfigAdapter.
*/
export interface ConfigService {
readSoul(): Promise<SoulConfig>;
readUser(): Promise<UserConfig>;
readTools(): Promise<ToolsConfig>;
writeSoul(config: SoulConfig): Promise<void>;
writeUser(config: UserConfig): Promise<void>;
writeTools(config: ToolsConfig): Promise<void>;
syncFramework(action: InstallAction): Promise<void>;
}
export function createConfigService(
mosaicHome: string,
sourceDir: string,
): ConfigService {
return new FileConfigAdapter(mosaicHome, sourceDir);
}

View File

@@ -1,163 +0,0 @@
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import type { ConfigService } from './config-service.js';
import type {
SoulConfig,
UserConfig,
ToolsConfig,
InstallAction,
} from '../types.js';
import { soulSchema, userSchema, toolsSchema } from './schemas.js';
import { renderTemplate } from '../template/engine.js';
import {
buildSoulTemplateVars,
buildUserTemplateVars,
buildToolsTemplateVars,
} from '../template/builders.js';
import { atomicWrite, backupFile, syncDirectory } from '../platform/file-ops.js';
/**
* Parse a SoulConfig from an existing SOUL.md file.
*/
function parseSoulFromMarkdown(content: string): SoulConfig {
const config: SoulConfig = {};
const nameMatch = content.match(/You are \*\*(.+?)\*\*/);
if (nameMatch) config.agentName = nameMatch[1];
const roleMatch = content.match(/Role identity: (.+)/);
if (roleMatch) config.roleDescription = roleMatch[1];
if (content.includes('Be direct, concise')) {
config.communicationStyle = 'direct';
} else if (content.includes('Be warm and conversational')) {
config.communicationStyle = 'friendly';
} else if (content.includes('Use professional, structured')) {
config.communicationStyle = 'formal';
}
return config;
}
/**
* Parse a UserConfig from an existing USER.md file.
*/
function parseUserFromMarkdown(content: string): UserConfig {
const config: UserConfig = {};
const nameMatch = content.match(/\*\*Name:\*\* (.+)/);
if (nameMatch) config.userName = nameMatch[1];
const pronounsMatch = content.match(/\*\*Pronouns:\*\* (.+)/);
if (pronounsMatch) config.pronouns = pronounsMatch[1];
const tzMatch = content.match(/\*\*Timezone:\*\* (.+)/);
if (tzMatch) config.timezone = tzMatch[1];
return config;
}
/**
* Parse a ToolsConfig from an existing TOOLS.md file.
*/
function parseToolsFromMarkdown(content: string): ToolsConfig {
const config: ToolsConfig = {};
const credsMatch = content.match(/\*\*Location:\*\* (.+)/);
if (credsMatch) config.credentialsLocation = credsMatch[1];
return config;
}
export class FileConfigAdapter implements ConfigService {
constructor(
private mosaicHome: string,
private sourceDir: string,
) {}
async readSoul(): Promise<SoulConfig> {
const path = join(this.mosaicHome, 'SOUL.md');
if (!existsSync(path)) return {};
return parseSoulFromMarkdown(readFileSync(path, 'utf-8'));
}
async readUser(): Promise<UserConfig> {
const path = join(this.mosaicHome, 'USER.md');
if (!existsSync(path)) return {};
return parseUserFromMarkdown(readFileSync(path, 'utf-8'));
}
async readTools(): Promise<ToolsConfig> {
const path = join(this.mosaicHome, 'TOOLS.md');
if (!existsSync(path)) return {};
return parseToolsFromMarkdown(readFileSync(path, 'utf-8'));
}
async writeSoul(config: SoulConfig): Promise<void> {
const validated = soulSchema.parse(config);
const templatePath = this.findTemplate('SOUL.md.template');
if (!templatePath) return;
const template = readFileSync(templatePath, 'utf-8');
const vars = buildSoulTemplateVars(validated);
const output = renderTemplate(template, vars);
const outPath = join(this.mosaicHome, 'SOUL.md');
backupFile(outPath);
atomicWrite(outPath, output);
}
async writeUser(config: UserConfig): Promise<void> {
const validated = userSchema.parse(config);
const templatePath = this.findTemplate('USER.md.template');
if (!templatePath) return;
const template = readFileSync(templatePath, 'utf-8');
const vars = buildUserTemplateVars(validated);
const output = renderTemplate(template, vars);
const outPath = join(this.mosaicHome, 'USER.md');
backupFile(outPath);
atomicWrite(outPath, output);
}
async writeTools(config: ToolsConfig): Promise<void> {
const validated = toolsSchema.parse(config);
const templatePath = this.findTemplate('TOOLS.md.template');
if (!templatePath) return;
const template = readFileSync(templatePath, 'utf-8');
const vars = buildToolsTemplateVars(validated);
const output = renderTemplate(template, vars);
const outPath = join(this.mosaicHome, 'TOOLS.md');
backupFile(outPath);
atomicWrite(outPath, output);
}
async syncFramework(action: InstallAction): Promise<void> {
const preservePaths =
action === 'keep' || action === 'reconfigure'
? ['SOUL.md', 'USER.md', 'TOOLS.md', 'memory']
: [];
syncDirectory(this.sourceDir, this.mosaicHome, {
preserve: preservePaths,
excludeGit: true,
});
}
/**
* Look for template in source dir first, then mosaic home.
*/
private findTemplate(name: string): string | null {
const candidates = [
join(this.sourceDir, 'templates', name),
join(this.mosaicHome, 'templates', name),
];
for (const path of candidates) {
if (existsSync(path)) return path;
}
return null;
}
}

View File

@@ -1,51 +0,0 @@
import { z } from 'zod';
export const communicationStyleSchema = z
.enum(['direct', 'friendly', 'formal'])
.default('direct');
export const soulSchema = z
.object({
agentName: z.string().min(1).max(50).default('Assistant'),
roleDescription: z
.string()
.default('execution partner and visibility engine'),
communicationStyle: communicationStyleSchema,
accessibility: z.string().default('none'),
customGuardrails: z.string().default(''),
})
.partial();
export const gitProviderSchema = z.object({
name: z.string().min(1),
url: z.string().min(1),
cli: z.string().min(1),
purpose: z.string().min(1),
});
export const userSchema = z
.object({
userName: z.string().default(''),
pronouns: z.string().default('They/Them'),
timezone: z.string().default('UTC'),
background: z.string().default('(not configured)'),
accessibilitySection: z
.string()
.default(
'(No specific accommodations configured. Edit this section to add any.)',
),
communicationPrefs: z.string().default(''),
personalBoundaries: z
.string()
.default('(Edit this section to add any personal boundaries.)'),
projectsTable: z.string().default(''),
})
.partial();
export const toolsSchema = z
.object({
gitProviders: z.array(gitProviderSchema).default([]),
credentialsLocation: z.string().default('none'),
customToolsSection: z.string().default(''),
})
.partial();

View File

@@ -1,38 +0,0 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
export const VERSION = '0.2.0';
export const DEFAULT_MOSAIC_HOME = join(homedir(), '.config', 'mosaic');
export const DEFAULTS = {
agentName: 'Assistant',
roleDescription: 'execution partner and visibility engine',
communicationStyle: 'direct' as const,
pronouns: 'They/Them',
timezone: 'UTC',
background: '(not configured)',
accessibilitySection: '(No specific accommodations configured. Edit this section to add any.)',
personalBoundaries: '(Edit this section to add any personal boundaries.)',
projectsTable: `| Project | Stack | Registry |
|---------|-------|----------|
| (none configured) | | |`,
credentialsLocation: 'none',
customToolsSection: `## Custom Tools
(Add any machine-specific tools, scripts, or workflows here.)`,
gitProvidersTable: `| Instance | URL | CLI | Purpose |
|----------|-----|-----|---------|
| (add your git providers here) | | | |`,
};
export const RECOMMENDED_SKILLS = new Set([
'brainstorming',
'code-review-excellence',
'lint',
'systematic-debugging',
'verification-before-completion',
'writing-plans',
'executing-plans',
'architecture-patterns',
]);

View File

@@ -1,20 +0,0 @@
export class WizardCancelledError extends Error {
override name = 'WizardCancelledError';
constructor() {
super('Wizard cancelled by user');
}
}
export class ValidationError extends Error {
override name = 'ValidationError';
constructor(message: string) {
super(message);
}
}
export class TemplateError extends Error {
override name = 'TemplateError';
constructor(templatePath: string, message: string) {
super(`Template error in ${templatePath}: ${message}`);
}
}

View File

@@ -1,81 +0,0 @@
import { Command } from 'commander';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { ClackPrompter } from './prompter/clack-prompter.js';
import { HeadlessPrompter } from './prompter/headless-prompter.js';
import { createConfigService } from './config/config-service.js';
import { runWizard } from './wizard.js';
import { WizardCancelledError } from './errors.js';
import { VERSION, DEFAULT_MOSAIC_HOME } from './constants.js';
import type { CommunicationStyle } from './types.js';
const program = new Command()
.name('mosaic-wizard')
.description('Mosaic Installation Wizard')
.version(VERSION);
program
.option('--non-interactive', 'Run without prompts (uses defaults + flags)')
.option(
'--source-dir <path>',
'Source directory for framework files',
)
.option(
'--mosaic-home <path>',
'Target config directory',
DEFAULT_MOSAIC_HOME,
)
// SOUL.md overrides
.option('--name <name>', 'Agent name')
.option('--role <description>', 'Agent role description')
.option('--style <style>', 'Communication style: direct|friendly|formal')
.option('--accessibility <prefs>', 'Accessibility preferences')
.option('--guardrails <rules>', 'Custom guardrails')
// USER.md overrides
.option('--user-name <name>', 'Your name')
.option('--pronouns <pronouns>', 'Your pronouns')
.option('--timezone <tz>', 'Your timezone')
.action(async (opts) => {
try {
const mosaicHome: string = opts.mosaicHome;
const sourceDir: string = opts.sourceDir ?? mosaicHome;
const prompter = opts.nonInteractive
? new HeadlessPrompter()
: new ClackPrompter();
const configService = createConfigService(mosaicHome, sourceDir);
const style = opts.style as CommunicationStyle | undefined;
await runWizard({
mosaicHome,
sourceDir,
prompter,
configService,
cliOverrides: {
soul: {
agentName: opts.name,
roleDescription: opts.role,
communicationStyle: style,
accessibility: opts.accessibility,
customGuardrails: opts.guardrails,
},
user: {
userName: opts.userName,
pronouns: opts.pronouns,
timezone: opts.timezone,
},
},
});
} catch (err) {
if (err instanceof WizardCancelledError) {
console.log('\nWizard cancelled.');
process.exit(0);
}
console.error('Wizard failed:', err);
process.exit(1);
}
});
program.parse();

View File

@@ -1,44 +0,0 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, platform } from 'node:os';
export type ShellType = 'zsh' | 'bash' | 'fish' | 'powershell' | 'unknown';
export function detectShell(): ShellType {
const shell = process.env.SHELL ?? '';
if (shell.includes('zsh')) return 'zsh';
if (shell.includes('bash')) return 'bash';
if (shell.includes('fish')) return 'fish';
if (platform() === 'win32') return 'powershell';
return 'unknown';
}
export function getShellProfilePath(): string | null {
const home = homedir();
if (platform() === 'win32') {
return join(
home,
'Documents',
'PowerShell',
'Microsoft.PowerShell_profile.ps1',
);
}
const shell = detectShell();
switch (shell) {
case 'zsh': {
const zdotdir = process.env.ZDOTDIR ?? home;
return join(zdotdir, '.zshrc');
}
case 'bash': {
const bashrc = join(home, '.bashrc');
if (existsSync(bashrc)) return bashrc;
return join(home, '.profile');
}
case 'fish':
return join(home, '.config', 'fish', 'config.fish');
default:
return join(home, '.profile');
}
}

View File

@@ -1,116 +0,0 @@
import {
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
copyFileSync,
renameSync,
readdirSync,
unlinkSync,
cpSync,
statSync,
} from 'node:fs';
import { dirname, join, relative } from 'node:path';
const MAX_BACKUPS = 3;
/**
* Atomic write: write to temp file, then rename.
* Creates parent directories as needed.
*/
export function atomicWrite(filePath: string, content: string): void {
mkdirSync(dirname(filePath), { recursive: true });
const tmpPath = `${filePath}.tmp-${process.pid}`;
writeFileSync(tmpPath, content, 'utf-8');
renameSync(tmpPath, filePath);
}
/**
* Create a backup of a file before overwriting.
* Rotates backups to keep at most MAX_BACKUPS.
*/
export function backupFile(filePath: string): string | null {
if (!existsSync(filePath)) return null;
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '')
.replace('T', '-')
.slice(0, 19);
const backupPath = `${filePath}.bak-${timestamp}`;
copyFileSync(filePath, backupPath);
rotateBackups(filePath);
return backupPath;
}
function rotateBackups(filePath: string): void {
const dir = dirname(filePath);
const baseName = filePath.split('/').pop()!;
const prefix = `${baseName}.bak-`;
try {
const backups = readdirSync(dir)
.filter((f) => f.startsWith(prefix))
.sort()
.reverse();
for (let i = MAX_BACKUPS; i < backups.length; i++) {
unlinkSync(join(dir, backups[i]));
}
} catch {
// Non-fatal: backup rotation failure doesn't block writes
}
}
/**
* Sync a source directory to a target, with optional preserve paths.
* Replaces the rsync/cp logic from install.sh.
*/
export function syncDirectory(
source: string,
target: string,
options: { preserve?: string[]; excludeGit?: boolean } = {},
): void {
const preserveSet = new Set(options.preserve ?? []);
// Collect files from source
function copyRecursive(src: string, dest: string, relBase: string): void {
if (!existsSync(src)) return;
const stat = statSync(src);
if (stat.isDirectory()) {
const relPath = relative(relBase, src);
// Skip .git
if (options.excludeGit && relPath === '.git') return;
// Skip preserved paths at top level
if (preserveSet.has(relPath) && existsSync(dest)) return;
mkdirSync(dest, { recursive: true });
for (const entry of readdirSync(src)) {
copyRecursive(join(src, entry), join(dest, entry), relBase);
}
} else {
const relPath = relative(relBase, src);
// Skip preserved files at top level
if (preserveSet.has(relPath) && existsSync(dest)) return;
mkdirSync(dirname(dest), { recursive: true });
copyFileSync(src, dest);
}
}
copyRecursive(source, target, source);
}
/**
* Safely read a file, returning null if it doesn't exist.
*/
export function safeReadFile(filePath: string): string | null {
try {
return readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}

View File

@@ -1,157 +0,0 @@
import * as p from '@clack/prompts';
import { WizardCancelledError } from '../errors.js';
import type {
WizardPrompter,
SelectOption,
MultiSelectOption,
ProgressHandle,
} from './interface.js';
function guardCancel<T>(value: T | symbol): T {
if (p.isCancel(value)) {
throw new WizardCancelledError();
}
return value as T;
}
export class ClackPrompter implements WizardPrompter {
intro(message: string): void {
p.intro(message);
}
outro(message: string): void {
p.outro(message);
}
note(message: string, title?: string): void {
p.note(message, title);
}
log(message: string): void {
p.log.info(message);
}
warn(message: string): void {
p.log.warn(message);
}
async text(opts: {
message: string;
placeholder?: string;
defaultValue?: string;
validate?: (value: string) => string | void;
}): Promise<string> {
const validate = opts.validate
? (v: string) => {
const r = opts.validate!(v);
return r === undefined ? undefined : r;
}
: undefined;
const result = await p.text({
message: opts.message,
placeholder: opts.placeholder,
defaultValue: opts.defaultValue,
validate,
});
return guardCancel(result);
}
async confirm(opts: {
message: string;
initialValue?: boolean;
}): Promise<boolean> {
const result = await p.confirm({
message: opts.message,
initialValue: opts.initialValue,
});
return guardCancel(result);
}
async select<T>(opts: {
message: string;
options: SelectOption<T>[];
initialValue?: T;
}): Promise<T> {
const clackOptions = opts.options.map((o) => ({
value: o.value as T,
label: o.label,
hint: o.hint,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- clack Option conditional type needs concrete Primitive
const result = await p.select({
message: opts.message,
options: clackOptions as any,
initialValue: opts.initialValue,
});
return guardCancel(result) as T;
}
async multiselect<T>(opts: {
message: string;
options: MultiSelectOption<T>[];
required?: boolean;
}): Promise<T[]> {
const clackOptions = opts.options.map((o) => ({
value: o.value as T,
label: o.label,
hint: o.hint,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await p.multiselect({
message: opts.message,
options: clackOptions as any,
required: opts.required,
initialValues: opts.options
.filter((o) => o.selected)
.map((o) => o.value),
});
return guardCancel(result) as T[];
}
async groupMultiselect<T>(opts: {
message: string;
options: Record<string, MultiSelectOption<T>[]>;
required?: boolean;
}): Promise<T[]> {
const grouped: Record<string, { value: T; label: string; hint?: string }[]> = {};
for (const [group, items] of Object.entries(opts.options)) {
grouped[group] = items.map((o) => ({
value: o.value as T,
label: o.label,
hint: o.hint,
}));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await p.groupMultiselect({
message: opts.message,
options: grouped as any,
required: opts.required,
});
return guardCancel(result) as T[];
}
spinner(): ProgressHandle {
const s = p.spinner();
let started = false;
return {
update(message: string) {
if (!started) {
s.start(message);
started = true;
} else {
s.message(message);
}
},
stop(message?: string) {
if (started) {
s.stop(message);
started = false;
}
},
};
}
separator(): void {
p.log.info('');
}
}

View File

@@ -1,133 +0,0 @@
import type {
WizardPrompter,
SelectOption,
MultiSelectOption,
ProgressHandle,
} from './interface.js';
export type AnswerValue = string | boolean | string[];
export class HeadlessPrompter implements WizardPrompter {
private answers: Map<string, AnswerValue>;
private logs: string[] = [];
constructor(answers: Record<string, AnswerValue> = {}) {
this.answers = new Map(Object.entries(answers));
}
intro(message: string): void {
this.logs.push(`[intro] ${message}`);
}
outro(message: string): void {
this.logs.push(`[outro] ${message}`);
}
note(message: string, title?: string): void {
this.logs.push(`[note] ${title ?? ''}: ${message}`);
}
log(message: string): void {
this.logs.push(`[log] ${message}`);
}
warn(message: string): void {
this.logs.push(`[warn] ${message}`);
}
async text(opts: {
message: string;
placeholder?: string;
defaultValue?: string;
validate?: (value: string) => string | void;
}): Promise<string> {
const answer = this.answers.get(opts.message);
const value =
typeof answer === 'string'
? answer
: opts.defaultValue !== undefined
? opts.defaultValue
: undefined;
if (value === undefined) {
throw new Error(`HeadlessPrompter: no answer for "${opts.message}"`);
}
if (opts.validate) {
const error = opts.validate(value);
if (error) throw new Error(`HeadlessPrompter validation failed for "${opts.message}": ${error}`);
}
return value;
}
async confirm(opts: {
message: string;
initialValue?: boolean;
}): Promise<boolean> {
const answer = this.answers.get(opts.message);
if (typeof answer === 'boolean') return answer;
return opts.initialValue ?? true;
}
async select<T>(opts: {
message: string;
options: SelectOption<T>[];
initialValue?: T;
}): Promise<T> {
const answer = this.answers.get(opts.message);
if (answer !== undefined) {
// Find matching option by value string comparison
const match = opts.options.find(
(o) => String(o.value) === String(answer),
);
if (match) return match.value;
}
if (opts.initialValue !== undefined) return opts.initialValue;
if (opts.options.length === 0) {
throw new Error(`HeadlessPrompter: no options for "${opts.message}"`);
}
return opts.options[0].value;
}
async multiselect<T>(opts: {
message: string;
options: MultiSelectOption<T>[];
required?: boolean;
}): Promise<T[]> {
const answer = this.answers.get(opts.message);
if (Array.isArray(answer)) {
return opts.options
.filter((o) => answer.includes(String(o.value)))
.map((o) => o.value);
}
return opts.options.filter((o) => o.selected).map((o) => o.value);
}
async groupMultiselect<T>(opts: {
message: string;
options: Record<string, MultiSelectOption<T>[]>;
required?: boolean;
}): Promise<T[]> {
const answer = this.answers.get(opts.message);
if (Array.isArray(answer)) {
const all = Object.values(opts.options).flat();
return all
.filter((o) => answer.includes(String(o.value)))
.map((o) => o.value);
}
return Object.values(opts.options)
.flat()
.filter((o) => o.selected)
.map((o) => o.value);
}
spinner(): ProgressHandle {
return {
update(_message: string) {},
stop(_message?: string) {},
};
}
separator(): void {}
getLogs(): string[] {
return [...this.logs];
}
}

View File

@@ -1,56 +0,0 @@
export interface SelectOption<T = string> {
value: T;
label: string;
hint?: string;
}
export interface MultiSelectOption<T = string> extends SelectOption<T> {
selected?: boolean;
}
export interface ProgressHandle {
update(message: string): void;
stop(message?: string): void;
}
export interface WizardPrompter {
intro(message: string): void;
outro(message: string): void;
note(message: string, title?: string): void;
log(message: string): void;
warn(message: string): void;
text(opts: {
message: string;
placeholder?: string;
defaultValue?: string;
validate?: (value: string) => string | void;
}): Promise<string>;
confirm(opts: {
message: string;
initialValue?: boolean;
}): Promise<boolean>;
select<T>(opts: {
message: string;
options: SelectOption<T>[];
initialValue?: T;
}): Promise<T>;
multiselect<T>(opts: {
message: string;
options: MultiSelectOption<T>[];
required?: boolean;
}): Promise<T[]>;
groupMultiselect<T>(opts: {
message: string;
options: Record<string, MultiSelectOption<T>[]>;
required?: boolean;
}): Promise<T[]>;
spinner(): ProgressHandle;
separator(): void;
}

View File

@@ -1,83 +0,0 @@
import { execSync } from 'node:child_process';
import { platform } from 'node:os';
import type { RuntimeName } from '../types.js';
export interface RuntimeInfo {
name: RuntimeName;
label: string;
installed: boolean;
path?: string;
version?: string;
installHint: string;
}
const RUNTIME_DEFS: Record<
RuntimeName,
{ label: string; command: string; versionFlag: string; installHint: string }
> = {
claude: {
label: 'Claude Code',
command: 'claude',
versionFlag: '--version',
installHint: 'npm install -g @anthropic-ai/claude-code',
},
codex: {
label: 'Codex',
command: 'codex',
versionFlag: '--version',
installHint: 'npm install -g @openai/codex',
},
opencode: {
label: 'OpenCode',
command: 'opencode',
versionFlag: 'version',
installHint: 'See https://opencode.ai for install instructions',
},
};
export function detectRuntime(name: RuntimeName): RuntimeInfo {
const def = RUNTIME_DEFS[name];
const isWindows = platform() === 'win32';
const whichCmd = isWindows
? `where ${def.command} 2>nul`
: `which ${def.command} 2>/dev/null`;
try {
const path = execSync(whichCmd, {
encoding: 'utf-8',
timeout: 5000,
})
.trim()
.split('\n')[0];
let version: string | undefined;
try {
version = execSync(`${def.command} ${def.versionFlag} 2>/dev/null`, {
encoding: 'utf-8',
timeout: 5000,
}).trim();
} catch {
// Version detection is optional
}
return {
name,
label: def.label,
installed: true,
path,
version,
installHint: def.installHint,
};
} catch {
return {
name,
label: def.label,
installed: false,
installHint: def.installHint,
};
}
}
export function getInstallInstructions(name: RuntimeName): string {
return RUNTIME_DEFS[name].installHint;
}

View File

@@ -1,12 +0,0 @@
import type { RuntimeName } from '../types.js';
import { getInstallInstructions } from './detector.js';
export function formatInstallInstructions(name: RuntimeName): string {
const hint = getInstallInstructions(name);
const labels: Record<RuntimeName, string> = {
claude: 'Claude Code',
codex: 'Codex',
opencode: 'OpenCode',
};
return `To install ${labels[name]}:\n ${hint}`;
}

View File

@@ -1,112 +0,0 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import type { RuntimeName } from '../types.js';
const MCP_ENTRY = {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
};
export function configureMcpForRuntime(runtime: RuntimeName): void {
switch (runtime) {
case 'claude':
return configureClaudeMcp();
case 'codex':
return configureCodexMcp();
case 'opencode':
return configureOpenCodeMcp();
}
}
function ensureDir(filePath: string): void {
mkdirSync(dirname(filePath), { recursive: true });
}
function configureClaudeMcp(): void {
const settingsPath = join(homedir(), '.claude', 'settings.json');
ensureDir(settingsPath);
let data: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
try {
data = JSON.parse(readFileSync(settingsPath, 'utf-8'));
} catch {
// Start fresh if corrupt
}
}
if (
!data.mcpServers ||
typeof data.mcpServers !== 'object' ||
Array.isArray(data.mcpServers)
) {
data.mcpServers = {};
}
(data.mcpServers as Record<string, unknown>)['sequential-thinking'] =
MCP_ENTRY;
writeFileSync(
settingsPath,
JSON.stringify(data, null, 2) + '\n',
'utf-8',
);
}
function configureCodexMcp(): void {
const configPath = join(homedir(), '.codex', 'config.toml');
ensureDir(configPath);
let content = '';
if (existsSync(configPath)) {
content = readFileSync(configPath, 'utf-8');
// Remove existing sequential-thinking section
content = content
.replace(
/\[mcp_servers\.(sequential-thinking|sequential_thinking)\][\s\S]*?(?=\n\[|$)/g,
'',
)
.trim();
}
content +=
'\n\n[mcp_servers.sequential-thinking]\n' +
'command = "npx"\n' +
'args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]\n';
writeFileSync(configPath, content, 'utf-8');
}
function configureOpenCodeMcp(): void {
const configPath = join(
homedir(),
'.config',
'opencode',
'config.json',
);
ensureDir(configPath);
let data: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
data = JSON.parse(readFileSync(configPath, 'utf-8'));
} catch {
// Start fresh
}
}
if (!data.mcp || typeof data.mcp !== 'object' || Array.isArray(data.mcp)) {
data.mcp = {};
}
(data.mcp as Record<string, unknown>)['sequential-thinking'] = {
type: 'local',
command: ['npx', '-y', '@modelcontextprotocol/server-sequential-thinking'],
enabled: true,
};
writeFileSync(
configPath,
JSON.stringify(data, null, 2) + '\n',
'utf-8',
);
}

View File

@@ -1,99 +0,0 @@
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { parse as parseYaml } from 'yaml';
import { RECOMMENDED_SKILLS } from '../constants.js';
export interface SkillEntry {
name: string;
description: string;
version?: string;
recommended: boolean;
source: 'canonical' | 'local';
}
export function loadSkillsCatalog(mosaicHome: string): SkillEntry[] {
const skills: SkillEntry[] = [];
// Load canonical skills
const canonicalDir = join(mosaicHome, 'skills');
if (existsSync(canonicalDir)) {
skills.push(...loadSkillsFromDir(canonicalDir, 'canonical'));
}
// Fallback to source repo
const sourceDir = join(mosaicHome, 'sources', 'agent-skills', 'skills');
if (skills.length === 0 && existsSync(sourceDir)) {
skills.push(...loadSkillsFromDir(sourceDir, 'canonical'));
}
// Load local skills
const localDir = join(mosaicHome, 'skills-local');
if (existsSync(localDir)) {
skills.push(...loadSkillsFromDir(localDir, 'local'));
}
return skills.sort((a, b) => a.name.localeCompare(b.name));
}
function loadSkillsFromDir(
dir: string,
source: 'canonical' | 'local',
): SkillEntry[] {
const entries: SkillEntry[] = [];
let dirEntries;
try {
dirEntries = readdirSync(dir, { withFileTypes: true });
} catch {
return entries;
}
for (const entry of dirEntries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
const skillMdPath = join(dir, entry.name, 'SKILL.md');
if (!existsSync(skillMdPath)) continue;
try {
const content = readFileSync(skillMdPath, 'utf-8');
const frontmatter = parseFrontmatter(content);
entries.push({
name: (frontmatter.name as string) ?? entry.name,
description: (frontmatter.description as string) ?? '',
version: frontmatter.version as string | undefined,
recommended: RECOMMENDED_SKILLS.has(entry.name),
source,
});
} catch {
// Skip malformed skills
entries.push({
name: entry.name,
description: '',
recommended: RECOMMENDED_SKILLS.has(entry.name),
source,
});
}
}
return entries;
}
function parseFrontmatter(content: string): Record<string, unknown> {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
try {
return (parseYaml(match[1]) as Record<string, unknown>) ?? {};
} catch {
// Fallback: simple key-value parsing
const result: Record<string, string> = {};
for (const line of match[1].split('\n')) {
const kv = line.match(/^(\w[\w-]*)\s*:\s*(.+)/);
if (kv) {
result[kv[1]] = kv[2].replace(/^['"]|['"]$/g, '');
}
}
return result;
}
}

View File

@@ -1,86 +0,0 @@
/**
* Skill category definitions and mapping.
* Skills are assigned to categories by name, with keyword fallback.
*/
export const SKILL_CATEGORIES: Record<string, string[]> = {
'Frontend & UI': [
'ai-sdk', 'algorithmic-art', 'antfu', 'canvas-design', 'frontend-design',
'next-best-practices', 'nuxt', 'pinia', 'shadcn-ui', 'slidev',
'tailwind-design-system', 'theme-factory', 'ui-animation', 'unocss',
'vercel-composition-patterns', 'vercel-react-best-practices',
'vercel-react-native-skills', 'vue', 'vue-best-practices',
'vue-router-best-practices', 'vueuse-functions', 'web-artifacts-builder',
'web-design-guidelines', 'vite', 'vitepress',
],
'Backend & Infrastructure': [
'architecture-patterns', 'fastapi', 'mcp-builder', 'nestjs-best-practices',
'python-performance-optimization', 'tsdown', 'turborepo', 'pnpm',
'dispatching-parallel-agents', 'subagent-driven-development', 'create-agent',
'proactive-agent', 'using-superpowers', 'kickstart', 'executing-plans',
],
'Testing & Quality': [
'code-review-excellence', 'lint', 'pr-reviewer', 'receiving-code-review',
'requesting-code-review', 'systematic-debugging', 'test-driven-development',
'verification-before-completion', 'vitest', 'vue-testing-best-practices',
'webapp-testing',
],
'Marketing & Growth': [
'ab-test-setup', 'analytics-tracking', 'competitor-alternatives',
'copy-editing', 'copywriting', 'email-sequence', 'form-cro',
'free-tool-strategy', 'launch-strategy', 'marketing-ideas',
'marketing-psychology', 'onboarding-cro', 'page-cro', 'paid-ads',
'paywall-upgrade-cro', 'popup-cro', 'pricing-strategy',
'product-marketing-context', 'programmatic-seo', 'referral-program',
'schema-markup', 'seo-audit', 'signup-flow-cro', 'social-content',
],
'Product & Strategy': [
'brainstorming', 'brand-guidelines', 'content-strategy',
'writing-plans', 'skill-creator', 'writing-skills', 'prd',
],
'Developer Practices': [
'finishing-a-development-branch', 'using-git-worktrees',
],
'Auth & Security': [
'better-auth-best-practices', 'create-auth-skill',
'email-and-password-best-practices', 'organization-best-practices',
'two-factor-authentication-best-practices',
],
'Content & Documentation': [
'doc-coauthoring', 'docx', 'internal-comms', 'pdf', 'pptx',
'slack-gif-creator', 'xlsx',
],
};
// Reverse lookup: skill name -> category
const SKILL_TO_CATEGORY = new Map<string, string>();
for (const [category, skills] of Object.entries(SKILL_CATEGORIES)) {
for (const skill of skills) {
SKILL_TO_CATEGORY.set(skill, category);
}
}
export function categorizeSkill(name: string, description: string): string {
const mapped = SKILL_TO_CATEGORY.get(name);
if (mapped) return mapped;
return inferCategoryFromDescription(description);
}
function inferCategoryFromDescription(desc: string): string {
const lower = desc.toLowerCase();
if (/\b(react|vue|css|frontend|ui|component|tailwind|design)\b/.test(lower))
return 'Frontend & UI';
if (/\b(api|backend|server|docker|infra|deploy)\b/.test(lower))
return 'Backend & Infrastructure';
if (/\b(test|lint|review|debug|quality)\b/.test(lower))
return 'Testing & Quality';
if (/\b(marketing|seo|copy|ads|cro|conversion|email)\b/.test(lower))
return 'Marketing & Growth';
if (/\b(auth|security|2fa|password|credential)\b/.test(lower))
return 'Auth & Security';
if (/\b(doc|pdf|word|sheet|writing|comms)\b/.test(lower))
return 'Content & Documentation';
if (/\b(product|strategy|brainstorm|plan|prd)\b/.test(lower))
return 'Product & Strategy';
return 'Developer Practices';
}

View File

@@ -1,95 +0,0 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { WizardPrompter } from '../prompter/interface.js';
import type { ConfigService } from '../config/config-service.js';
import type { WizardState, InstallAction } from '../types.js';
function detectExistingInstall(mosaicHome: string): boolean {
if (!existsSync(mosaicHome)) return false;
return (
existsSync(join(mosaicHome, 'bin/mosaic')) ||
existsSync(join(mosaicHome, 'AGENTS.md')) ||
existsSync(join(mosaicHome, 'SOUL.md'))
);
}
function detectExistingIdentity(mosaicHome: string): {
hasSoul: boolean;
hasUser: boolean;
hasTools: boolean;
agentName?: string;
} {
const soulPath = join(mosaicHome, 'SOUL.md');
const hasSoul = existsSync(soulPath);
let agentName: string | undefined;
if (hasSoul) {
try {
const content = readFileSync(soulPath, 'utf-8');
const match = content.match(/You are \*\*(.+?)\*\*/);
agentName = match?.[1];
} catch {
// Non-fatal
}
}
return {
hasSoul,
hasUser: existsSync(join(mosaicHome, 'USER.md')),
hasTools: existsSync(join(mosaicHome, 'TOOLS.md')),
agentName,
};
}
export async function detectInstallStage(
p: WizardPrompter,
state: WizardState,
config: ConfigService,
): Promise<void> {
const existing = detectExistingInstall(state.mosaicHome);
if (!existing) {
state.installAction = 'fresh';
return;
}
const identity = detectExistingIdentity(state.mosaicHome);
const identitySummary = identity.agentName
? `Agent: ${identity.agentName}`
: 'No identity configured';
p.note(
`Found existing Mosaic installation at:\n${state.mosaicHome}\n\n` +
`${identitySummary}\n` +
`SOUL.md: ${identity.hasSoul ? 'yes' : 'no'}\n` +
`USER.md: ${identity.hasUser ? 'yes' : 'no'}\n` +
`TOOLS.md: ${identity.hasTools ? 'yes' : 'no'}`,
'Existing Installation Detected',
);
state.installAction = await p.select<InstallAction>({
message: 'What would you like to do?',
options: [
{
value: 'keep',
label: 'Keep identity, update framework',
hint: 'Preserves SOUL.md, USER.md, TOOLS.md, memory/',
},
{
value: 'reconfigure',
label: 'Reconfigure identity',
hint: 'Re-run identity setup, update framework',
},
{
value: 'reset',
label: 'Fresh install',
hint: 'Replace everything',
},
],
});
if (state.installAction === 'keep') {
state.soul = await config.readSoul();
state.user = await config.readUser();
state.tools = await config.readTools();
}
}

View File

@@ -1,177 +0,0 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
import { platform } from 'node:os';
import type { WizardPrompter } from '../prompter/interface.js';
import type { ConfigService } from '../config/config-service.js';
import type { WizardState } from '../types.js';
import { getShellProfilePath } from '../platform/detect.js';
function linkRuntimeAssets(mosaicHome: string): void {
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
if (existsSync(script)) {
try {
spawnSync('bash', [script], { timeout: 30000, stdio: 'pipe' });
} catch {
// Non-fatal: wizard continues
}
}
}
function syncSkills(mosaicHome: string): void {
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
if (existsSync(script)) {
try {
spawnSync('bash', [script], { timeout: 60000, stdio: 'pipe' });
} catch {
// Non-fatal
}
}
}
interface DoctorResult {
warnings: number;
output: string;
}
function runDoctor(mosaicHome: string): DoctorResult {
const script = join(mosaicHome, 'bin', 'mosaic-doctor');
if (!existsSync(script)) {
return { warnings: 0, output: 'mosaic-doctor not found' };
}
try {
const result = spawnSync('bash', [script], {
timeout: 30000,
encoding: 'utf-8',
stdio: 'pipe',
});
const output = result.stdout ?? '';
const warnings = (output.match(/WARN/g) ?? []).length;
return { warnings, output };
} catch {
return { warnings: 1, output: 'Doctor check failed' };
}
}
type PathAction = 'already' | 'added' | 'skipped';
function setupPath(
mosaicHome: string,
p: WizardPrompter,
): PathAction {
const binDir = join(mosaicHome, 'bin');
const currentPath = process.env.PATH ?? '';
if (currentPath.includes(binDir)) {
return 'already';
}
const profilePath = getShellProfilePath();
if (!profilePath) return 'skipped';
const isWindows = platform() === 'win32';
const exportLine = isWindows
? `\n# Mosaic\n$env:Path = "${binDir};$env:Path"\n`
: `\n# Mosaic\nexport PATH="${binDir}:$PATH"\n`;
// Check if already in profile
if (existsSync(profilePath)) {
const content = readFileSync(profilePath, 'utf-8');
if (content.includes(binDir)) {
return 'already';
}
}
try {
appendFileSync(profilePath, exportLine, 'utf-8');
return 'added';
} catch {
return 'skipped';
}
}
export async function finalizeStage(
p: WizardPrompter,
state: WizardState,
config: ConfigService,
): Promise<void> {
p.separator();
const spin = p.spinner();
// 1. Sync framework files (before config writes so identity files aren't overwritten)
spin.update('Syncing framework files...');
await config.syncFramework(state.installAction);
// 2. Write config files (after sync so they aren't overwritten by source templates)
if (state.installAction !== 'keep') {
spin.update('Writing configuration files...');
await config.writeSoul(state.soul);
await config.writeUser(state.user);
await config.writeTools(state.tools);
}
// 3. Link runtime assets
spin.update('Linking runtime assets...');
linkRuntimeAssets(state.mosaicHome);
// 4. Sync skills
if (state.selectedSkills.length > 0) {
spin.update('Syncing skills...');
syncSkills(state.mosaicHome);
}
// 5. Run doctor
spin.update('Running health audit...');
const doctorResult = runDoctor(state.mosaicHome);
spin.stop('Installation complete');
// 6. PATH setup
const pathAction = setupPath(state.mosaicHome, p);
// 7. Summary
const summary: string[] = [
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
`Skills: ${state.selectedSkills.length} selected`,
`Config: ${state.mosaicHome}`,
];
if (doctorResult.warnings > 0) {
summary.push(
`Health: ${doctorResult.warnings} warning(s) — run 'mosaic doctor' for details`,
);
} else {
summary.push('Health: all checks passed');
}
p.note(summary.join('\n'), 'Installation Summary');
// 8. Next steps
const nextSteps: string[] = [];
if (pathAction === 'added') {
const profilePath = getShellProfilePath();
nextSteps.push(
`Reload shell: source ${profilePath ?? '~/.profile'}`,
);
}
if (state.runtimes.detected.length === 0) {
nextSteps.push(
'Install at least one runtime (claude, codex, or opencode)',
);
}
nextSteps.push("Launch with 'mosaic claude' (or codex/opencode)");
nextSteps.push(
'Edit identity files directly in ~/.config/mosaic/ for fine-tuning',
);
p.note(
nextSteps.map((s, i) => `${i + 1}. ${s}`).join('\n'),
'Next Steps',
);
p.outro('Mosaic is ready.');
}

View File

@@ -1,23 +0,0 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState, WizardMode } from '../types.js';
export async function modeSelectStage(
p: WizardPrompter,
state: WizardState,
): Promise<void> {
state.mode = await p.select<WizardMode>({
message: 'Installation mode',
options: [
{
value: 'quick',
label: 'Quick Start',
hint: 'Sensible defaults, minimal questions (~2 min)',
},
{
value: 'advanced',
label: 'Advanced',
hint: 'Full customization of identity, runtimes, and skills',
},
],
});
}

View File

@@ -1,70 +0,0 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState, RuntimeName } from '../types.js';
import { detectRuntime, type RuntimeInfo } from '../runtime/detector.js';
import { formatInstallInstructions } from '../runtime/installer.js';
import { configureMcpForRuntime } from '../runtime/mcp-config.js';
const RUNTIME_NAMES: RuntimeName[] = ['claude', 'codex', 'opencode'];
export async function runtimeSetupStage(
p: WizardPrompter,
state: WizardState,
): Promise<void> {
p.separator();
const spin = p.spinner();
spin.update('Detecting installed runtimes...');
const runtimes: RuntimeInfo[] = RUNTIME_NAMES.map(detectRuntime);
spin.stop('Runtime detection complete');
const detected = runtimes.filter((r) => r.installed);
const notDetected = runtimes.filter((r) => !r.installed);
if (detected.length > 0) {
const summary = detected
.map(
(r) =>
` ${r.label}: ${r.version ?? 'installed'} (${r.path})`,
)
.join('\n');
p.note(summary, 'Detected Runtimes');
} else {
p.warn('No runtimes detected. Install at least one to use Mosaic.');
}
state.runtimes.detected = detected.map((r) => r.name);
// Offer installation info for missing runtimes in advanced mode
if (state.mode === 'advanced' && notDetected.length > 0) {
const showInstall = await p.confirm({
message: `${notDetected.length} runtime(s) not found. Show install instructions?`,
initialValue: false,
});
if (showInstall) {
for (const rt of notDetected) {
p.note(formatInstallInstructions(rt.name), `Install ${rt.label}`);
}
}
}
// Configure MCP sequential-thinking for detected runtimes
if (detected.length > 0) {
const spin2 = p.spinner();
spin2.update('Configuring sequential-thinking MCP...');
try {
for (const rt of detected) {
configureMcpForRuntime(rt.name);
}
spin2.stop('MCP sequential-thinking configured');
state.runtimes.mcpConfigured = true;
} catch (err) {
spin2.stop('MCP configuration failed (non-fatal)');
p.warn(
`MCP setup failed: ${err instanceof Error ? err.message : String(err)}. Run 'mosaic seq fix' later.`,
);
}
}
}

View File

@@ -1,84 +0,0 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState } from '../types.js';
import { loadSkillsCatalog } from '../skills/catalog.js';
import { SKILL_CATEGORIES, categorizeSkill } from '../skills/categories.js';
function truncate(str: string, max: number): string {
if (str.length <= max) return str;
return str.slice(0, max - 1) + '\u2026';
}
export async function skillsSelectStage(
p: WizardPrompter,
state: WizardState,
): Promise<void> {
p.separator();
const spin = p.spinner();
spin.update('Loading skills catalog...');
const catalog = loadSkillsCatalog(state.mosaicHome);
spin.stop(`Found ${catalog.length} available skills`);
if (catalog.length === 0) {
p.warn(
"No skills found. Run 'mosaic sync' after installation to fetch skills.",
);
state.selectedSkills = [];
return;
}
if (state.mode === 'quick') {
const defaults = catalog
.filter((s) => s.recommended)
.map((s) => s.name);
state.selectedSkills = defaults;
p.note(
`Selected ${defaults.length} recommended skills.\n` +
`Run 'mosaic sync' later to browse the full catalog.`,
'Skills',
);
return;
}
// Advanced mode: categorized browsing
p.note(
'Skills give agents domain expertise for specific tasks.\n' +
'Browse by category and select the ones you want.\n' +
"You can always change this later with 'mosaic sync'.",
'Skills Selection',
);
// Build grouped options
const grouped: Record<
string,
{ value: string; label: string; hint?: string; selected?: boolean }[]
> = {};
// Initialize all categories
for (const categoryName of Object.keys(SKILL_CATEGORIES)) {
grouped[categoryName] = [];
}
for (const skill of catalog) {
const category = categorizeSkill(skill.name, skill.description);
if (!grouped[category]) grouped[category] = [];
grouped[category].push({
value: skill.name,
label: skill.name,
hint: truncate(skill.description, 60),
selected: skill.recommended,
});
}
// Remove empty categories
for (const key of Object.keys(grouped)) {
if (grouped[key].length === 0) delete grouped[key];
}
state.selectedSkills = await p.groupMultiselect({
message: 'Select skills (space to toggle)',
options: grouped,
required: false,
});
}

View File

@@ -1,73 +0,0 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState, CommunicationStyle } from '../types.js';
import { DEFAULTS } from '../constants.js';
export async function soulSetupStage(
p: WizardPrompter,
state: WizardState,
): Promise<void> {
if (state.installAction === 'keep') return;
p.separator();
p.note(
'Your agent identity defines how AI assistants behave,\n' +
'their principles, and communication style.\n' +
'This creates SOUL.md.',
'Agent Identity',
);
if (!state.soul.agentName) {
state.soul.agentName = await p.text({
message: 'What name should agents use?',
placeholder: 'e.g., Jarvis, Assistant, Mosaic',
defaultValue: DEFAULTS.agentName,
validate: (v) => {
if (v.length === 0) return 'Name cannot be empty';
if (v.length > 50) return 'Name must be under 50 characters';
},
});
}
if (state.mode === 'advanced') {
if (!state.soul.roleDescription) {
state.soul.roleDescription = await p.text({
message: 'Agent role description',
placeholder: 'e.g., execution partner and visibility engine',
defaultValue: DEFAULTS.roleDescription,
});
}
} else {
state.soul.roleDescription ??= DEFAULTS.roleDescription;
}
if (!state.soul.communicationStyle) {
state.soul.communicationStyle = await p.select<CommunicationStyle>({
message: 'Communication style',
options: [
{ value: 'direct', label: 'Direct', hint: 'Concise, no fluff, actionable' },
{ value: 'friendly', label: 'Friendly', hint: 'Warm but efficient, conversational' },
{ value: 'formal', label: 'Formal', hint: 'Professional, structured, thorough' },
],
initialValue: 'direct',
});
}
if (state.mode === 'advanced') {
if (!state.soul.accessibility) {
state.soul.accessibility = await p.text({
message: 'Accessibility preferences',
placeholder:
"e.g., ADHD-friendly chunking, dyslexia-aware formatting, or 'none'",
defaultValue: 'none',
});
}
if (!state.soul.customGuardrails) {
state.soul.customGuardrails = await p.text({
message: 'Custom guardrails (optional)',
placeholder: 'e.g., Never auto-commit to main',
defaultValue: '',
});
}
}
}

View File

@@ -1,76 +0,0 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState, GitProvider } from '../types.js';
import { DEFAULTS } from '../constants.js';
export async function toolsSetupStage(
p: WizardPrompter,
state: WizardState,
): Promise<void> {
if (state.installAction === 'keep') return;
if (state.mode === 'quick') {
state.tools.gitProviders = [];
state.tools.credentialsLocation = DEFAULTS.credentialsLocation;
state.tools.customToolsSection = DEFAULTS.customToolsSection;
return;
}
p.separator();
p.note(
'Tool configuration tells agents about your git providers,\n' +
'credential locations, and custom tools.\n' +
'This creates TOOLS.md.',
'Tool Reference',
);
const addProviders = await p.confirm({
message: 'Configure git providers?',
initialValue: false,
});
state.tools.gitProviders = [];
if (addProviders) {
let addMore = true;
while (addMore) {
const name = await p.text({
message: 'Provider name',
placeholder: 'e.g., Gitea, GitHub',
});
const url = await p.text({
message: 'Provider URL',
placeholder: 'e.g., https://github.com',
});
const cli = await p.select<string>({
message: 'CLI tool',
options: [
{ value: 'gh', label: 'gh (GitHub CLI)' },
{ value: 'tea', label: 'tea (Gitea CLI)' },
{ value: 'glab', label: 'glab (GitLab CLI)' },
],
});
const purpose = await p.text({
message: 'Purpose',
placeholder: 'e.g., Primary code hosting',
defaultValue: 'Code hosting',
});
state.tools.gitProviders.push({
name,
url,
cli,
purpose,
} satisfies GitProvider);
addMore = await p.confirm({
message: 'Add another provider?',
initialValue: false,
});
}
}
state.tools.credentialsLocation = await p.text({
message: 'Credential file path',
placeholder: "e.g., ~/.secrets/credentials.env, or 'none'",
defaultValue: DEFAULTS.credentialsLocation,
});
}

View File

@@ -1,80 +0,0 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState } from '../types.js';
import { DEFAULTS } from '../constants.js';
import { buildCommunicationPrefs } from '../template/builders.js';
export async function userSetupStage(
p: WizardPrompter,
state: WizardState,
): Promise<void> {
if (state.installAction === 'keep') return;
p.separator();
p.note(
'Your user profile helps agents understand your context,\n' +
'accessibility needs, and communication preferences.\n' +
'This creates USER.md.',
'User Profile',
);
if (!state.user.userName) {
state.user.userName = await p.text({
message: 'Your name',
placeholder: 'How agents should address you',
defaultValue: '',
});
}
if (!state.user.pronouns) {
state.user.pronouns = await p.text({
message: 'Your pronouns',
placeholder: 'e.g., He/Him, She/Her, They/Them',
defaultValue: DEFAULTS.pronouns,
});
}
// Auto-detect timezone
let detectedTz: string;
try {
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
detectedTz = DEFAULTS.timezone;
}
if (!state.user.timezone) {
state.user.timezone = await p.text({
message: 'Your timezone',
placeholder: `e.g., ${detectedTz}`,
defaultValue: detectedTz,
});
}
if (state.mode === 'advanced') {
state.user.background = await p.text({
message: 'Professional background (brief)',
placeholder: 'e.g., Full-stack developer, 10 years TypeScript/React',
defaultValue: DEFAULTS.background,
});
state.user.accessibilitySection = await p.text({
message: 'Neurodivergence / accessibility accommodations',
placeholder: 'e.g., ADHD-friendly chunking, or press Enter to skip',
defaultValue: DEFAULTS.accessibilitySection,
});
state.user.personalBoundaries = await p.text({
message: 'Personal boundaries for agents',
placeholder: 'e.g., No unsolicited career advice, or press Enter to skip',
defaultValue: DEFAULTS.personalBoundaries,
});
} else {
state.user.background = DEFAULTS.background;
state.user.accessibilitySection = DEFAULTS.accessibilitySection;
state.user.personalBoundaries = DEFAULTS.personalBoundaries;
}
// Derive communication preferences from SOUL style
state.user.communicationPrefs = buildCommunicationPrefs(
state.soul.communicationStyle ?? 'direct',
);
}

View File

@@ -1,18 +0,0 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState } from '../types.js';
import { VERSION } from '../constants.js';
export async function welcomeStage(
p: WizardPrompter,
_state: WizardState,
): Promise<void> {
p.intro(`Mosaic Installation Wizard v${VERSION}`);
p.note(
`Mosaic is an agent framework that gives AI coding assistants\n` +
`a persistent identity, shared skills, and structured workflows.\n\n` +
`It works with Claude Code, Codex, and OpenCode.\n\n` +
`All config is stored locally in ~/.config/mosaic/.\n` +
`No data is sent anywhere. No accounts required.`,
'What is Mosaic?',
);
}

View File

@@ -1,145 +0,0 @@
import type { CommunicationStyle, SoulConfig, UserConfig, ToolsConfig, GitProvider } from '../types.js';
import { DEFAULTS } from '../constants.js';
import type { TemplateVars } from './engine.js';
/**
* Build behavioral principles text based on communication style.
* Replicates mosaic-init lines 177-204 exactly.
*/
function buildBehavioralPrinciples(
style: CommunicationStyle,
accessibility?: string,
): string {
let principles: string;
switch (style) {
case 'direct':
principles = `1. Clarity over performance theater.
2. Practical execution over abstract planning.
3. Truthfulness over confidence: state uncertainty explicitly.
4. Visible state over hidden assumptions.
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations.`;
break;
case 'friendly':
principles = `1. Be helpful and approachable while staying efficient.
2. Provide context and explain reasoning when helpful.
3. Truthfulness over confidence: state uncertainty explicitly.
4. Visible state over hidden assumptions.
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations.`;
break;
case 'formal':
principles = `1. Maintain professional, structured communication.
2. Provide thorough analysis with explicit tradeoffs.
3. Truthfulness over confidence: state uncertainty explicitly.
4. Document decisions and rationale clearly.
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations.`;
break;
}
if (accessibility && accessibility !== 'none' && accessibility.length > 0) {
principles += `\n6. ${accessibility}.`;
}
return principles;
}
/**
* Build communication style text based on style choice.
* Replicates mosaic-init lines 208-227 exactly.
*/
function buildCommunicationStyleText(style: CommunicationStyle): string {
switch (style) {
case 'direct':
return `- Be direct, concise, and concrete.
- Avoid fluff, hype, and anthropomorphic roleplay.
- Do not simulate certainty when facts are missing.
- Prefer actionable next steps and explicit tradeoffs.`;
case 'friendly':
return `- Be warm and conversational while staying focused.
- Explain your reasoning when it helps the user.
- Do not simulate certainty when facts are missing.
- Prefer actionable next steps with clear context.`;
case 'formal':
return `- Use professional, structured language.
- Provide thorough explanations with supporting detail.
- Do not simulate certainty when facts are missing.
- Present options with explicit tradeoffs and recommendations.`;
}
}
/**
* Build communication preferences for USER.md based on style.
* Replicates mosaic-init lines 299-316 exactly.
*/
function buildCommunicationPrefs(style: CommunicationStyle): string {
switch (style) {
case 'direct':
return `- Direct and concise
- No sycophancy
- Executive summaries and tables for overview`;
case 'friendly':
return `- Warm and conversational
- Explain reasoning when helpful
- Balance thoroughness with brevity`;
case 'formal':
return `- Professional and structured
- Thorough explanations with supporting detail
- Formal tone with explicit recommendations`;
}
}
/**
* Build git providers markdown table from provider list.
* Replicates mosaic-init lines 362-384.
*/
function buildGitProvidersTable(providers?: GitProvider[]): string {
if (!providers || providers.length === 0) {
return DEFAULTS.gitProvidersTable;
}
const rows = providers
.map((p) => `| ${p.name} | ${p.url} | \`${p.cli}\` | ${p.purpose} |`)
.join('\n');
return `| Instance | URL | CLI | Purpose |
|----------|-----|-----|---------|
${rows}`;
}
export function buildSoulTemplateVars(config: SoulConfig): TemplateVars {
const style = config.communicationStyle ?? 'direct';
const guardrails = config.customGuardrails
? `- ${config.customGuardrails}`
: '';
return {
AGENT_NAME: config.agentName ?? DEFAULTS.agentName,
ROLE_DESCRIPTION: config.roleDescription ?? DEFAULTS.roleDescription,
BEHAVIORAL_PRINCIPLES: buildBehavioralPrinciples(style, config.accessibility),
COMMUNICATION_STYLE: buildCommunicationStyleText(style),
CUSTOM_GUARDRAILS: guardrails,
};
}
export function buildUserTemplateVars(config: UserConfig): TemplateVars {
return {
USER_NAME: config.userName ?? '',
PRONOUNS: config.pronouns ?? DEFAULTS.pronouns,
TIMEZONE: config.timezone ?? DEFAULTS.timezone,
BACKGROUND: config.background ?? DEFAULTS.background,
ACCESSIBILITY_SECTION: config.accessibilitySection ?? DEFAULTS.accessibilitySection,
COMMUNICATION_PREFS: config.communicationPrefs ?? buildCommunicationPrefs('direct'),
PERSONAL_BOUNDARIES: config.personalBoundaries ?? DEFAULTS.personalBoundaries,
PROJECTS_TABLE: config.projectsTable ?? DEFAULTS.projectsTable,
};
}
export function buildToolsTemplateVars(config: ToolsConfig): TemplateVars {
return {
GIT_PROVIDERS_TABLE: buildGitProvidersTable(config.gitProviders),
CREDENTIALS_LOCATION: config.credentialsLocation ?? DEFAULTS.credentialsLocation,
CUSTOM_TOOLS_SECTION: config.customToolsSection ?? DEFAULTS.customToolsSection,
};
}
export { buildCommunicationPrefs };

View File

@@ -1,26 +0,0 @@
export interface TemplateVars {
[key: string]: string;
}
/**
* Replaces {{PLACEHOLDER}} tokens with provided values.
* Does NOT expand ${ENV_VAR} syntax — those pass through for shell resolution.
*/
export function renderTemplate(
template: string,
vars: TemplateVars,
options: { strict?: boolean } = {},
): string {
return template.replace(
/\{\{([A-Z_][A-Z0-9_]*)\}\}/g,
(match, varName: string) => {
if (varName in vars) {
return vars[varName];
}
if (options.strict) {
throw new Error(`Template variable not provided: {{${varName}}}`);
}
return '';
},
);
}

View File

@@ -1,53 +0,0 @@
export type WizardMode = 'quick' | 'advanced';
export type InstallAction = 'fresh' | 'keep' | 'reconfigure' | 'reset';
export type CommunicationStyle = 'direct' | 'friendly' | 'formal';
export type RuntimeName = 'claude' | 'codex' | 'opencode';
export interface SoulConfig {
agentName?: string;
roleDescription?: string;
communicationStyle?: CommunicationStyle;
accessibility?: string;
customGuardrails?: string;
}
export interface UserConfig {
userName?: string;
pronouns?: string;
timezone?: string;
background?: string;
accessibilitySection?: string;
communicationPrefs?: string;
personalBoundaries?: string;
projectsTable?: string;
}
export interface GitProvider {
name: string;
url: string;
cli: string;
purpose: string;
}
export interface ToolsConfig {
gitProviders?: GitProvider[];
credentialsLocation?: string;
customToolsSection?: string;
}
export interface RuntimeState {
detected: RuntimeName[];
mcpConfigured: boolean;
}
export interface WizardState {
mosaicHome: string;
sourceDir: string;
mode: WizardMode;
installAction: InstallAction;
soul: SoulConfig;
user: UserConfig;
tools: ToolsConfig;
runtimes: RuntimeState;
selectedSkills: string[];
}

View File

@@ -1,96 +0,0 @@
import type { WizardPrompter } from './prompter/interface.js';
import type { ConfigService } from './config/config-service.js';
import type { WizardState } from './types.js';
import { welcomeStage } from './stages/welcome.js';
import { detectInstallStage } from './stages/detect-install.js';
import { modeSelectStage } from './stages/mode-select.js';
import { soulSetupStage } from './stages/soul-setup.js';
import { userSetupStage } from './stages/user-setup.js';
import { toolsSetupStage } from './stages/tools-setup.js';
import { runtimeSetupStage } from './stages/runtime-setup.js';
import { skillsSelectStage } from './stages/skills-select.js';
import { finalizeStage } from './stages/finalize.js';
export interface WizardOptions {
mosaicHome: string;
sourceDir: string;
prompter: WizardPrompter;
configService: ConfigService;
cliOverrides?: Partial<WizardState>;
}
export async function runWizard(options: WizardOptions): Promise<void> {
const { prompter, configService, mosaicHome, sourceDir } = options;
const state: WizardState = {
mosaicHome,
sourceDir,
mode: 'quick',
installAction: 'fresh',
soul: {},
user: {},
tools: {},
runtimes: { detected: [], mcpConfigured: false },
selectedSkills: [],
};
// Apply CLI overrides (strip undefined values)
if (options.cliOverrides) {
if (options.cliOverrides.soul) {
for (const [k, v] of Object.entries(options.cliOverrides.soul)) {
if (v !== undefined) {
(state.soul as Record<string, unknown>)[k] = v;
}
}
}
if (options.cliOverrides.user) {
for (const [k, v] of Object.entries(options.cliOverrides.user)) {
if (v !== undefined) {
(state.user as Record<string, unknown>)[k] = v;
}
}
}
if (options.cliOverrides.tools) {
for (const [k, v] of Object.entries(options.cliOverrides.tools)) {
if (v !== undefined) {
(state.tools as Record<string, unknown>)[k] = v;
}
}
}
if (options.cliOverrides.mode) {
state.mode = options.cliOverrides.mode;
}
}
// Stage 1: Welcome
await welcomeStage(prompter, state);
// Stage 2: Existing Install Detection
await detectInstallStage(prompter, state, configService);
// Stage 3: Quick Start vs Advanced (skip if keeping existing)
if (state.installAction === 'fresh' || state.installAction === 'reset') {
await modeSelectStage(prompter, state);
} else if (state.installAction === 'reconfigure') {
state.mode = 'advanced';
}
// Stage 4: SOUL.md
await soulSetupStage(prompter, state);
// Stage 5: USER.md
await userSetupStage(prompter, state);
// Stage 6: TOOLS.md
await toolsSetupStage(prompter, state);
// Stage 7: Runtime Detection & Installation
await runtimeSetupStage(prompter, state);
// Stage 8: Skills Selection
await skillsSelectStage(prompter, state);
// Stage 9: Finalize
await finalizeStage(prompter, state, configService);
}

View File

@@ -1,109 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
mkdtempSync,
mkdirSync,
writeFileSync,
readFileSync,
existsSync,
rmSync,
cpSync,
} from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
import { createConfigService } from '../../src/config/config-service.js';
import { runWizard } from '../../src/wizard.js';
describe('Full Wizard (headless)', () => {
let tmpDir: string;
const repoRoot = join(import.meta.dirname, '..', '..');
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-'));
// Copy templates to tmp dir
const templatesDir = join(repoRoot, 'templates');
if (existsSync(templatesDir)) {
cpSync(templatesDir, join(tmpDir, 'templates'), { recursive: true });
}
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('quick start produces valid SOUL.md', async () => {
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'They/Them',
'Your timezone': 'UTC',
});
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
});
const soulPath = join(tmpDir, 'SOUL.md');
expect(existsSync(soulPath)).toBe(true);
const soul = readFileSync(soulPath, 'utf-8');
expect(soul).toContain('You are **TestBot**');
expect(soul).toContain('Be direct, concise, and concrete');
expect(soul).toContain('execution partner and visibility engine');
});
it('quick start produces valid USER.md', async () => {
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'He/Him',
'Your timezone': 'America/Chicago',
});
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
});
const userPath = join(tmpDir, 'USER.md');
expect(existsSync(userPath)).toBe(true);
const user = readFileSync(userPath, 'utf-8');
expect(user).toContain('**Name:** Tester');
expect(user).toContain('**Pronouns:** He/Him');
expect(user).toContain('**Timezone:** America/Chicago');
});
it('applies CLI overrides', async () => {
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'Your name': 'FromPrompt',
});
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
cliOverrides: {
soul: {
agentName: 'FromCLI',
communicationStyle: 'formal',
},
},
});
const soul = readFileSync(join(tmpDir, 'SOUL.md'), 'utf-8');
expect(soul).toContain('You are **FromCLI**');
expect(soul).toContain('Use professional, structured language');
});
});

View File

@@ -1,71 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
import { detectInstallStage } from '../../src/stages/detect-install.js';
import type { WizardState } from '../../src/types.js';
import type { ConfigService } from '../../src/config/config-service.js';
function createState(mosaicHome: string): WizardState {
return {
mosaicHome,
sourceDir: mosaicHome,
mode: 'quick',
installAction: 'fresh',
soul: {},
user: {},
tools: {},
runtimes: { detected: [], mcpConfigured: false },
selectedSkills: [],
};
}
const mockConfig: ConfigService = {
readSoul: async () => ({ agentName: 'TestAgent' }),
readUser: async () => ({ userName: 'TestUser' }),
readTools: async () => ({}),
writeSoul: async () => {},
writeUser: async () => {},
writeTools: async () => {},
syncFramework: async () => {},
};
describe('detectInstallStage', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-test-'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('sets fresh for empty directory', async () => {
const p = new HeadlessPrompter({});
const state = createState(join(tmpDir, 'nonexistent'));
await detectInstallStage(p, state, mockConfig);
expect(state.installAction).toBe('fresh');
});
it('detects existing install and offers choices', async () => {
// Create a mock existing install
mkdirSync(join(tmpDir, 'bin'), { recursive: true });
writeFileSync(join(tmpDir, 'AGENTS.md'), '# Test');
writeFileSync(
join(tmpDir, 'SOUL.md'),
'You are **Jarvis** in this session.',
);
const p = new HeadlessPrompter({
'What would you like to do?': 'keep',
});
const state = createState(tmpDir);
await detectInstallStage(p, state, mockConfig);
expect(state.installAction).toBe('keep');
expect(state.soul.agentName).toBe('TestAgent');
});
});

View File

@@ -1,74 +0,0 @@
import { describe, it, expect } from 'vitest';
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
import { soulSetupStage } from '../../src/stages/soul-setup.js';
import type { WizardState } from '../../src/types.js';
function createState(overrides: Partial<WizardState> = {}): WizardState {
return {
mosaicHome: '/tmp/test-mosaic',
sourceDir: '/tmp/test-mosaic',
mode: 'quick',
installAction: 'fresh',
soul: {},
user: {},
tools: {},
runtimes: { detected: [], mcpConfigured: false },
selectedSkills: [],
...overrides,
};
}
describe('soulSetupStage', () => {
it('sets agent name and style in quick mode', async () => {
const p = new HeadlessPrompter({
'What name should agents use?': 'Jarvis',
'Communication style': 'friendly',
});
const state = createState({ mode: 'quick' });
await soulSetupStage(p, state);
expect(state.soul.agentName).toBe('Jarvis');
expect(state.soul.communicationStyle).toBe('friendly');
expect(state.soul.roleDescription).toBe(
'execution partner and visibility engine',
);
});
it('uses defaults in quick mode with no answers', async () => {
const p = new HeadlessPrompter({});
const state = createState({ mode: 'quick' });
await soulSetupStage(p, state);
expect(state.soul.agentName).toBe('Assistant');
expect(state.soul.communicationStyle).toBe('direct');
});
it('skips when install action is keep', async () => {
const p = new HeadlessPrompter({});
const state = createState({ installAction: 'keep' });
state.soul.agentName = 'Existing';
await soulSetupStage(p, state);
expect(state.soul.agentName).toBe('Existing');
});
it('asks for all fields in advanced mode', async () => {
const p = new HeadlessPrompter({
'What name should agents use?': 'Atlas',
'Agent role description': 'memory keeper',
'Communication style': 'formal',
'Accessibility preferences': 'ADHD-friendly',
'Custom guardrails (optional)': 'Never push to main',
});
const state = createState({ mode: 'advanced' });
await soulSetupStage(p, state);
expect(state.soul.agentName).toBe('Atlas');
expect(state.soul.roleDescription).toBe('memory keeper');
expect(state.soul.communicationStyle).toBe('formal');
expect(state.soul.accessibility).toBe('ADHD-friendly');
expect(state.soul.customGuardrails).toBe('Never push to main');
});
});

View File

@@ -1,60 +0,0 @@
import { describe, it, expect } from 'vitest';
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
import { userSetupStage } from '../../src/stages/user-setup.js';
import type { WizardState } from '../../src/types.js';
function createState(overrides: Partial<WizardState> = {}): WizardState {
return {
mosaicHome: '/tmp/test-mosaic',
sourceDir: '/tmp/test-mosaic',
mode: 'quick',
installAction: 'fresh',
soul: { communicationStyle: 'direct' },
user: {},
tools: {},
runtimes: { detected: [], mcpConfigured: false },
selectedSkills: [],
...overrides,
};
}
describe('userSetupStage', () => {
it('collects basic info in quick mode', async () => {
const p = new HeadlessPrompter({
'Your name': 'Jason',
'Your pronouns': 'He/Him',
'Your timezone': 'America/Chicago',
});
const state = createState({ mode: 'quick' });
await userSetupStage(p, state);
expect(state.user.userName).toBe('Jason');
expect(state.user.pronouns).toBe('He/Him');
expect(state.user.timezone).toBe('America/Chicago');
expect(state.user.communicationPrefs).toContain('Direct and concise');
});
it('skips when install action is keep', async () => {
const p = new HeadlessPrompter({});
const state = createState({ installAction: 'keep' });
state.user.userName = 'Existing';
await userSetupStage(p, state);
expect(state.user.userName).toBe('Existing');
});
it('derives communication prefs from soul style', async () => {
const p = new HeadlessPrompter({
'Your name': 'Test',
});
const state = createState({
mode: 'quick',
soul: { communicationStyle: 'friendly' },
});
await userSetupStage(p, state);
expect(state.user.communicationPrefs).toContain('Warm and conversational');
});
});

View File

@@ -1,99 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
buildSoulTemplateVars,
buildUserTemplateVars,
buildToolsTemplateVars,
} from '../../src/template/builders.js';
describe('buildSoulTemplateVars', () => {
it('builds direct style correctly', () => {
const vars = buildSoulTemplateVars({
agentName: 'Jarvis',
communicationStyle: 'direct',
});
expect(vars.AGENT_NAME).toBe('Jarvis');
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('Clarity over performance theater');
expect(vars.COMMUNICATION_STYLE).toContain('Be direct, concise, and concrete');
});
it('builds friendly style correctly', () => {
const vars = buildSoulTemplateVars({
communicationStyle: 'friendly',
});
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('Be helpful and approachable');
expect(vars.COMMUNICATION_STYLE).toContain('Be warm and conversational');
});
it('builds formal style correctly', () => {
const vars = buildSoulTemplateVars({
communicationStyle: 'formal',
});
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('Maintain professional, structured');
expect(vars.COMMUNICATION_STYLE).toContain('Use professional, structured language');
});
it('appends accessibility to principles', () => {
const vars = buildSoulTemplateVars({
communicationStyle: 'direct',
accessibility: 'ADHD-friendly chunking',
});
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('6. ADHD-friendly chunking.');
});
it('does not append accessibility when "none"', () => {
const vars = buildSoulTemplateVars({
communicationStyle: 'direct',
accessibility: 'none',
});
expect(vars.BEHAVIORAL_PRINCIPLES).not.toContain('6.');
});
it('formats custom guardrails', () => {
const vars = buildSoulTemplateVars({
customGuardrails: 'Never auto-commit',
});
expect(vars.CUSTOM_GUARDRAILS).toBe('- Never auto-commit');
});
it('uses defaults when config is empty', () => {
const vars = buildSoulTemplateVars({});
expect(vars.AGENT_NAME).toBe('Assistant');
expect(vars.ROLE_DESCRIPTION).toBe('execution partner and visibility engine');
});
});
describe('buildUserTemplateVars', () => {
it('maps all fields', () => {
const vars = buildUserTemplateVars({
userName: 'Jason',
pronouns: 'He/Him',
timezone: 'America/Chicago',
});
expect(vars.USER_NAME).toBe('Jason');
expect(vars.PRONOUNS).toBe('He/Him');
expect(vars.TIMEZONE).toBe('America/Chicago');
});
it('uses defaults for missing fields', () => {
const vars = buildUserTemplateVars({});
expect(vars.PRONOUNS).toBe('They/Them');
expect(vars.TIMEZONE).toBe('UTC');
});
});
describe('buildToolsTemplateVars', () => {
it('builds git providers table', () => {
const vars = buildToolsTemplateVars({
gitProviders: [
{ name: 'GitHub', url: 'https://github.com', cli: 'gh', purpose: 'OSS' },
],
});
expect(vars.GIT_PROVIDERS_TABLE).toContain('| GitHub |');
expect(vars.GIT_PROVIDERS_TABLE).toContain('`gh`');
});
it('uses default table when no providers', () => {
const vars = buildToolsTemplateVars({});
expect(vars.GIT_PROVIDERS_TABLE).toContain('add your git providers here');
});
});

View File

@@ -1,52 +0,0 @@
import { describe, it, expect } from 'vitest';
import { renderTemplate } from '../../src/template/engine.js';
describe('renderTemplate', () => {
it('replaces all placeholders', () => {
const template = 'You are **{{AGENT_NAME}}**, role: {{ROLE_DESCRIPTION}}';
const result = renderTemplate(template, {
AGENT_NAME: 'Jarvis',
ROLE_DESCRIPTION: 'steward',
});
expect(result).toBe('You are **Jarvis**, role: steward');
});
it('preserves ${ENV_VAR} references', () => {
const template = 'Path: ${HOME}/.config, Agent: {{AGENT_NAME}}';
const result = renderTemplate(template, { AGENT_NAME: 'Test' });
expect(result).toBe('Path: ${HOME}/.config, Agent: Test');
});
it('handles multi-line values', () => {
const template = '{{PRINCIPLES}}';
const result = renderTemplate(template, {
PRINCIPLES: '1. First\n2. Second\n3. Third',
});
expect(result).toBe('1. First\n2. Second\n3. Third');
});
it('replaces unset vars with empty string by default', () => {
const template = 'Before {{MISSING}} After';
const result = renderTemplate(template, {});
expect(result).toBe('Before After');
});
it('throws in strict mode for missing vars', () => {
const template = '{{MISSING}}';
expect(() => renderTemplate(template, {}, { strict: true })).toThrow(
'Template variable not provided: {{MISSING}}',
);
});
it('handles multiple occurrences of same placeholder', () => {
const template = '{{NAME}} says hello, {{NAME}}!';
const result = renderTemplate(template, { NAME: 'Jarvis' });
expect(result).toBe('Jarvis says hello, Jarvis!');
});
it('preserves non-placeholder curly braces', () => {
const template = 'const x = { foo: {{VALUE}} }';
const result = renderTemplate(template, { VALUE: '"bar"' });
expect(result).toBe('const x = { foo: "bar" }');
});
});

View File

@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@@ -1,20 +0,0 @@
import tsEslintPlugin from '@typescript-eslint/eslint-plugin';
import tsEslintParser from '@typescript-eslint/parser';
export default [
{
files: ['src/**/*.ts'],
languageOptions: {
parser: tsEslintParser,
sourceType: 'module',
ecmaVersion: 'latest',
},
plugins: {
'@typescript-eslint': tsEslintPlugin,
},
rules: {
'no-console': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
},
];

View File

@@ -1,30 +0,0 @@
{
"name": "@mosaic/quality-rails",
"version": "0.1.0",
"type": "module",
"description": "Mosaic quality rails - TypeScript code quality scaffolder",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"test": "vitest run"
},
"dependencies": {
"@mosaic/types": "workspace:*",
"commander": "^13",
"js-yaml": "^4"
},
"devDependencies": {
"@types/node": "^22",
"@types/js-yaml": "^4",
"@typescript-eslint/eslint-plugin": "^8",
"@typescript-eslint/parser": "^8",
"eslint": "^9",
"typescript": "^5",
"vitest": "^2"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
"access": "public"
}
}

View File

@@ -1,193 +0,0 @@
import { constants } from 'node:fs';
import { access } from 'node:fs/promises';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Command } from 'commander';
import { detectProjectKind } from './detect.js';
import { scaffoldQualityRails } from './scaffolder.js';
import type { ProjectKind, QualityProfile, RailsConfig } from './types.js';
const VALID_PROFILES: readonly QualityProfile[] = ['strict', 'standard', 'minimal'];
async function fileExists(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.F_OK);
return true;
} catch {
return false;
}
}
function parseProfile(rawProfile: string): QualityProfile {
if (VALID_PROFILES.includes(rawProfile as QualityProfile)) {
return rawProfile as QualityProfile;
}
throw new Error(`Invalid profile: ${rawProfile}. Use one of ${VALID_PROFILES.join(', ')}.`);
}
function defaultLinters(kind: ProjectKind): string[] {
if (kind === 'node') {
return ['eslint', 'biome'];
}
if (kind === 'python') {
return ['ruff'];
}
if (kind === 'rust') {
return ['clippy'];
}
return [];
}
function defaultFormatters(kind: ProjectKind): string[] {
if (kind === 'node') {
return ['prettier'];
}
if (kind === 'python') {
return ['black'];
}
if (kind === 'rust') {
return ['rustfmt'];
}
return [];
}
function expectedFilesForKind(kind: ProjectKind): string[] {
if (kind === 'node') {
return ['.eslintrc', 'biome.json', '.githooks/pre-commit', 'PR-CHECKLIST.md'];
}
if (kind === 'python') {
return ['pyproject.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'];
}
if (kind === 'rust') {
return ['rustfmt.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'];
}
return ['.githooks/pre-commit', 'PR-CHECKLIST.md'];
}
function printScaffoldResult(config: RailsConfig, filesWritten: string[], warnings: string[], commandsToRun: string[]): void {
console.log(`[quality-rails] initialized at ${config.projectPath}`);
console.log(`kind=${config.kind} profile=${config.profile}`);
if (filesWritten.length > 0) {
console.log('files written:');
for (const filePath of filesWritten) {
console.log(` - ${filePath}`);
}
}
if (commandsToRun.length > 0) {
console.log('run next:');
for (const command of commandsToRun) {
console.log(` - ${command}`);
}
}
if (warnings.length > 0) {
console.log('warnings:');
for (const warning of warnings) {
console.log(` - ${warning}`);
}
}
}
export function createQualityRailsCli(): Command {
const program = new Command('mosaic');
const qualityRails = program.command('quality-rails').description('Manage quality rails scaffolding');
qualityRails
.command('init')
.requiredOption('--project <path>', 'Project path')
.option('--profile <profile>', 'strict|standard|minimal', 'standard')
.action(async (options: { project: string; profile: string }) => {
const profile = parseProfile(options.profile);
const projectPath = resolve(options.project);
const kind = await detectProjectKind(projectPath);
const config: RailsConfig = {
projectPath,
kind,
profile,
linters: defaultLinters(kind),
formatters: defaultFormatters(kind),
hooks: true,
};
const result = await scaffoldQualityRails(config);
printScaffoldResult(config, result.filesWritten, result.warnings, result.commandsToRun);
});
qualityRails
.command('check')
.requiredOption('--project <path>', 'Project path')
.action(async (options: { project: string }) => {
const projectPath = resolve(options.project);
const kind = await detectProjectKind(projectPath);
const expected = expectedFilesForKind(kind);
const missing: string[] = [];
for (const relativePath of expected) {
const exists = await fileExists(resolve(projectPath, relativePath));
if (!exists) {
missing.push(relativePath);
}
}
if (missing.length > 0) {
console.error('[quality-rails] missing files:');
for (const relativePath of missing) {
console.error(` - ${relativePath}`);
}
process.exitCode = 1;
return;
}
console.log(`[quality-rails] all expected files present for ${kind} project`);
});
qualityRails
.command('doctor')
.requiredOption('--project <path>', 'Project path')
.action(async (options: { project: string }) => {
const projectPath = resolve(options.project);
const kind = await detectProjectKind(projectPath);
const expected = expectedFilesForKind(kind);
console.log(`[quality-rails] doctor for ${projectPath}`);
console.log(`detected project kind: ${kind}`);
for (const relativePath of expected) {
const exists = await fileExists(resolve(projectPath, relativePath));
console.log(` - ${exists ? 'ok' : 'missing'}: ${relativePath}`);
}
if (kind === 'unknown') {
console.log('recommendation: add package.json, pyproject.toml, or Cargo.toml for better defaults.');
}
});
return program;
}
export async function runQualityRailsCli(argv: string[] = process.argv): Promise<void> {
const program = createQualityRailsCli();
await program.parseAsync(argv);
}
const entryPath = process.argv[1] ? resolve(process.argv[1]) : '';
if (entryPath.length > 0 && entryPath === fileURLToPath(import.meta.url)) {
runQualityRailsCli().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}

View File

@@ -1,30 +0,0 @@
import { constants } from 'node:fs';
import { access } from 'node:fs/promises';
import { join } from 'node:path';
import type { ProjectKind } from './types.js';
async function fileExists(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.F_OK);
return true;
} catch {
return false;
}
}
export async function detectProjectKind(projectPath: string): Promise<ProjectKind> {
if (await fileExists(join(projectPath, 'package.json'))) {
return 'node';
}
if (await fileExists(join(projectPath, 'pyproject.toml'))) {
return 'python';
}
if (await fileExists(join(projectPath, 'Cargo.toml'))) {
return 'rust';
}
return 'unknown';
}

View File

@@ -1,5 +0,0 @@
export * from './cli.js';
export * from './detect.js';
export * from './scaffolder.js';
export * from './templates.js';
export * from './types.js';

View File

@@ -1,201 +0,0 @@
import { spawn } from 'node:child_process';
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import {
biomeTemplate,
eslintTemplate,
prChecklistTemplate,
preCommitHookTemplate,
pyprojectSection,
rustfmtTemplate,
} from './templates.js';
import type { RailsConfig, ScaffoldResult } from './types.js';
const PYPROJECT_START_MARKER = '# >>> mosaic-quality-rails >>>';
const PYPROJECT_END_MARKER = '# <<< mosaic-quality-rails <<<';
async function ensureDirectory(filePath: string): Promise<void> {
await mkdir(dirname(filePath), { recursive: true });
}
async function writeRelativeFile(
projectPath: string,
relativePath: string,
contents: string,
result: ScaffoldResult,
): Promise<void> {
const absolutePath = join(projectPath, relativePath);
await ensureDirectory(absolutePath);
await writeFile(absolutePath, contents, { encoding: 'utf8', mode: 0o644 });
result.filesWritten.push(relativePath);
}
async function upsertPyproject(
projectPath: string,
profile: RailsConfig['profile'],
result: ScaffoldResult,
): Promise<void> {
const pyprojectPath = join(projectPath, 'pyproject.toml');
const nextSection = pyprojectSection(profile);
let previous = '';
try {
previous = await readFile(pyprojectPath, 'utf8');
} catch {
previous = '';
}
const existingStart = previous.indexOf(PYPROJECT_START_MARKER);
const existingEnd = previous.indexOf(PYPROJECT_END_MARKER);
if (existingStart >= 0 && existingEnd > existingStart) {
const before = previous.slice(0, existingStart).trimEnd();
const after = previous.slice(existingEnd + PYPROJECT_END_MARKER.length).trimStart();
const rebuilt = [before, nextSection.trim(), after]
.filter((segment) => segment.length > 0)
.join('\n\n');
await writeRelativeFile(projectPath, 'pyproject.toml', `${rebuilt}\n`, result);
return;
}
const separator = previous.trim().length > 0 ? '\n\n' : '';
await writeRelativeFile(projectPath, 'pyproject.toml', `${previous.trimEnd()}${separator}${nextSection}`, result);
}
function runCommand(command: string, args: string[], cwd: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: 'ignore',
env: process.env,
});
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`Command failed (${code ?? 'unknown'}): ${command} ${args.join(' ')}`));
});
});
}
function buildNodeDevDependencies(config: RailsConfig): string[] {
const dependencies = new Set<string>();
if (config.linters.some((linter) => linter.toLowerCase() === 'eslint')) {
dependencies.add('eslint');
dependencies.add('@typescript-eslint/parser');
dependencies.add('@typescript-eslint/eslint-plugin');
}
if (config.linters.some((linter) => linter.toLowerCase() === 'biome')) {
dependencies.add('@biomejs/biome');
}
if (config.formatters.some((formatter) => formatter.toLowerCase() === 'prettier')) {
dependencies.add('prettier');
}
if (config.hooks) {
dependencies.add('husky');
}
return [...dependencies];
}
async function installNodeDependencies(config: RailsConfig, result: ScaffoldResult): Promise<void> {
const dependencies = buildNodeDevDependencies(config);
if (dependencies.length === 0) {
return;
}
const commandLine = `pnpm add -D ${dependencies.join(' ')}`;
if (process.env.MOSAIC_QUALITY_RAILS_SKIP_INSTALL === '1') {
result.commandsToRun.push(commandLine);
return;
}
try {
await runCommand('pnpm', ['add', '-D', ...dependencies], config.projectPath);
} catch (error) {
result.warnings.push(
`Failed to auto-install Node dependencies: ${error instanceof Error ? error.message : String(error)}`,
);
result.commandsToRun.push(commandLine);
}
}
export async function scaffoldQualityRails(config: RailsConfig): Promise<ScaffoldResult> {
const result: ScaffoldResult = {
filesWritten: [],
commandsToRun: [],
warnings: [],
};
const normalizedLinters = new Set(config.linters.map((linter) => linter.toLowerCase()));
if (config.kind === 'node') {
if (normalizedLinters.has('eslint')) {
await writeRelativeFile(
config.projectPath,
'.eslintrc',
eslintTemplate(config.profile),
result,
);
}
if (normalizedLinters.has('biome')) {
await writeRelativeFile(
config.projectPath,
'biome.json',
biomeTemplate(config.profile),
result,
);
}
await installNodeDependencies(config, result);
}
if (config.kind === 'python') {
await upsertPyproject(config.projectPath, config.profile, result);
}
if (config.kind === 'rust') {
await writeRelativeFile(
config.projectPath,
'rustfmt.toml',
rustfmtTemplate(config.profile),
result,
);
}
if (config.hooks) {
await writeRelativeFile(
config.projectPath,
'.githooks/pre-commit',
preCommitHookTemplate(config),
result,
);
await chmod(join(config.projectPath, '.githooks/pre-commit'), 0o755);
result.commandsToRun.push('git config core.hooksPath .githooks');
}
await writeRelativeFile(
config.projectPath,
'PR-CHECKLIST.md',
prChecklistTemplate(config.profile),
result,
);
if (config.kind === 'unknown') {
result.warnings.push(
'Unable to detect project kind. Generated generic rails only (hooks + PR checklist).',
);
}
return result;
}

View File

@@ -1,182 +0,0 @@
import type { QualityProfile, RailsConfig } from './types.js';
const PROFILE_TO_MAX_WARNINGS: Record<QualityProfile, number> = {
strict: 0,
standard: 10,
minimal: 50,
};
const PROFILE_TO_LINE_LENGTH: Record<QualityProfile, number> = {
strict: 100,
standard: 110,
minimal: 120,
};
export function eslintTemplate(profile: QualityProfile): string {
return `${JSON.stringify(
{
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
env: {
node: true,
es2022: true,
},
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
'no-console': profile === 'strict' ? 'error' : 'warn',
'@typescript-eslint/no-explicit-any':
profile === 'minimal' ? 'off' : profile === 'strict' ? 'error' : 'warn',
'@typescript-eslint/explicit-function-return-type':
profile === 'strict' ? 'warn' : 'off',
'max-lines-per-function': [
profile === 'minimal' ? 'off' : 'warn',
{
max: profile === 'strict' ? 60 : 100,
skipBlankLines: true,
skipComments: true,
},
],
},
},
null,
2,
)}\n`;
}
export function biomeTemplate(profile: QualityProfile): string {
return `${JSON.stringify(
{
'$schema': 'https://biomejs.dev/schemas/1.8.3/schema.json',
formatter: {
enabled: true,
indentStyle: 'space',
indentWidth: 2,
lineWidth: PROFILE_TO_LINE_LENGTH[profile],
},
linter: {
enabled: true,
rules: {
recommended: true,
suspicious: {
noConsole: profile === 'strict' ? 'error' : 'warn',
},
complexity: {
noExcessiveCognitiveComplexity:
profile === 'strict' ? 'warn' : profile === 'standard' ? 'info' : 'off',
},
},
},
javascript: {
formatter: {
quoteStyle: 'single',
trailingCommas: 'all',
},
},
},
null,
2,
)}\n`;
}
export function pyprojectSection(profile: QualityProfile): string {
const lineLength = PROFILE_TO_LINE_LENGTH[profile];
return [
'# >>> mosaic-quality-rails >>>',
'[tool.ruff]',
`line-length = ${lineLength}`,
'target-version = "py311"',
'',
'[tool.ruff.lint]',
'select = ["E", "F", "I", "UP", "B"]',
`ignore = ${profile === 'minimal' ? '[]' : '["E501"]'}`,
'',
'[tool.black]',
`line-length = ${lineLength}`,
'',
'# <<< mosaic-quality-rails <<<',
'',
].join('\n');
}
export function rustfmtTemplate(profile: QualityProfile): string {
const maxWidth = PROFILE_TO_LINE_LENGTH[profile];
const useSmallHeuristics = profile === 'strict' ? 'Max' : 'Default';
return [
`max_width = ${maxWidth}`,
`use_small_heuristics = "${useSmallHeuristics}"`,
`imports_granularity = "${profile === 'minimal' ? 'Crate' : 'Module'}"`,
`group_imports = "${profile === 'strict' ? 'StdExternalCrate' : 'Preserve'}"`,
'',
].join('\n');
}
function resolveHookCommands(config: RailsConfig): string[] {
const commands: string[] = [];
if (config.kind === 'node') {
if (config.linters.some((linter) => linter.toLowerCase() === 'eslint')) {
commands.push('pnpm lint');
}
if (config.linters.some((linter) => linter.toLowerCase() === 'biome')) {
commands.push('pnpm biome check .');
}
if (config.formatters.some((formatter) => formatter.toLowerCase() === 'prettier')) {
commands.push('pnpm prettier --check .');
}
commands.push('pnpm test --if-present');
}
if (config.kind === 'python') {
commands.push('ruff check .');
commands.push('black --check .');
}
if (config.kind === 'rust') {
commands.push('cargo fmt --check');
commands.push('cargo clippy --all-targets --all-features -- -D warnings');
}
if (commands.length === 0) {
commands.push('echo "No quality commands configured for this project kind"');
}
return commands;
}
export function preCommitHookTemplate(config: RailsConfig): string {
const commands = resolveHookCommands(config)
.map((command) => `${command} || exit 1`)
.join('\n');
return [
'#!/usr/bin/env sh',
'set -eu',
'',
'echo "[quality-rails] Running pre-commit checks..."',
commands,
'echo "[quality-rails] Checks passed."',
'',
].join('\n');
}
export function prChecklistTemplate(profile: QualityProfile): string {
return [
'# Code Review Checklist',
'',
`Profile: **${profile}**`,
'',
'- [ ] Requirements mapped to tests',
'- [ ] Error handling covers unhappy paths',
'- [ ] Lint and typecheck are clean',
'- [ ] Test suite passes',
'- [ ] Security-sensitive paths reviewed',
`- [ ] Warnings count <= ${PROFILE_TO_MAX_WARNINGS[profile]}`,
'',
].join('\n');
}

View File

@@ -1,18 +0,0 @@
export type ProjectKind = 'node' | 'python' | 'rust' | 'unknown';
export type QualityProfile = 'strict' | 'standard' | 'minimal';
export interface RailsConfig {
projectPath: string;
kind: ProjectKind;
profile: QualityProfile;
linters: string[];
formatters: string[];
hooks: boolean;
}
export interface ScaffoldResult {
filesWritten: string[];
commandsToRun: string[];
warnings: string[];
}

View File

@@ -1,40 +0,0 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { detectProjectKind } from '../src/detect.js';
async function withTempDir(run: (directory: string) => Promise<void>): Promise<void> {
const directory = await mkdtemp(join(tmpdir(), 'quality-rails-detect-'));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
describe('detectProjectKind', () => {
it('returns node when package.json exists', async () => {
await withTempDir(async (directory) => {
await writeFile(join(directory, 'package.json'), '{"name":"fixture"}\n', 'utf8');
await expect(detectProjectKind(directory)).resolves.toBe('node');
});
});
it('returns python when pyproject.toml exists and package.json does not', async () => {
await withTempDir(async (directory) => {
await writeFile(join(directory, 'pyproject.toml'), '[project]\nname = "fixture"\n', 'utf8');
await expect(detectProjectKind(directory)).resolves.toBe('python');
});
});
it('returns unknown when no known project files exist', async () => {
await withTempDir(async (directory) => {
await expect(detectProjectKind(directory)).resolves.toBe('unknown');
});
});
});

View File

@@ -1,57 +0,0 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { scaffoldQualityRails } from '../src/scaffolder.js';
import type { RailsConfig } from '../src/types.js';
async function withTempDir(run: (directory: string) => Promise<void>): Promise<void> {
const directory = await mkdtemp(join(tmpdir(), 'quality-rails-scaffold-'));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
describe('scaffoldQualityRails', () => {
it('writes expected node quality rails files', async () => {
await withTempDir(async (directory) => {
await writeFile(join(directory, 'package.json'), '{"name":"fixture"}\n', 'utf8');
const previous = process.env.MOSAIC_QUALITY_RAILS_SKIP_INSTALL;
process.env.MOSAIC_QUALITY_RAILS_SKIP_INSTALL = '1';
const config: RailsConfig = {
projectPath: directory,
kind: 'node',
profile: 'strict',
linters: ['eslint', 'biome'],
formatters: ['prettier'],
hooks: true,
};
const result = await scaffoldQualityRails(config);
process.env.MOSAIC_QUALITY_RAILS_SKIP_INSTALL = previous;
await expect(readFile(join(directory, '.eslintrc'), 'utf8')).resolves.toContain('parser');
await expect(readFile(join(directory, 'biome.json'), 'utf8')).resolves.toContain('"formatter"');
await expect(readFile(join(directory, '.githooks', 'pre-commit'), 'utf8')).resolves.toContain('pnpm lint');
await expect(readFile(join(directory, 'PR-CHECKLIST.md'), 'utf8')).resolves.toContain('Code Review Checklist');
expect(result.filesWritten).toEqual(
expect.arrayContaining([
'.eslintrc',
'biome.json',
'.githooks/pre-commit',
'PR-CHECKLIST.md',
]),
);
expect(result.commandsToRun).toContain('git config core.hooksPath .githooks');
expect(result.warnings).toHaveLength(0);
});
});
});

View File

@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}

View File

@@ -30,7 +30,6 @@
"node": ">=20.0.0"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
"access": "public"
},
"dependencies": {

View File

@@ -10,9 +10,7 @@
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"files": ["dist"],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
@@ -21,9 +19,5 @@
},
"devDependencies": {
"typescript": "^5"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
"access": "public"
}
}

View File

@@ -1,97 +0,0 @@
# @mosaic/openclaw-context
OpenBrain-backed `ContextEngine` plugin for OpenClaw.
This plugin stores session context in OpenBrain over REST so context can be reassembled from recent history plus semantic matches instead of relying only on in-session compaction state.
## Features
- Registers context engine id: `openbrain`
- Typed OpenBrain REST client with Bearer auth
- Session-aware ingest + batch ingest
- Context assembly from recent + semantic search under token budget
- Compaction summaries archived to OpenBrain
- Subagent seed/result handoff helpers
## Requirements
- OpenClaw with plugin/context-engine support (`openclaw >= 2026.3.2`)
- Reachable OpenBrain REST API
- OpenBrain API key
## Install (local workspace plugin)
```bash
pnpm install
pnpm build
```
Then reference this plugin in your OpenClaw config.
## OpenBrain Setup (self-host or hosted)
You must provide both of these in plugin config:
- `baseUrl`: your OpenBrain API root (example: `https://brain.your-domain.com`)
- `apiKey`: Bearer token for your OpenBrain instance
No host or key fallback is built in. Missing `baseUrl` or `apiKey` throws `OpenBrainConfigError` at `bootstrap()`.
## Configuration
Plugin entry id: `openclaw-openbrain-context`
Context engine slot id: `openbrain`
### Config fields
- `baseUrl` (required, string): OpenBrain API base URL
- `apiKey` (required, string): OpenBrain Bearer token
- `source` (optional, string, default `openclaw`): source prefix; engine stores thoughts under `<source>:<sessionId>`
- `recentMessages` (optional, integer, default `20`): recent thoughts to fetch for bootstrap/assemble
- `semanticSearchLimit` (optional, integer, default `10`): semantic matches fetched in assemble
- `subagentRecentMessages` (optional, integer, default `8`): context lines used for subagent seed/result exchange
## Environment Variable Pattern
Use OpenClaw variable interpolation in `openclaw.json`:
```json
{
"apiKey": "${OPENBRAIN_API_KEY}"
}
```
Then set it in your shell/runtime environment before starting OpenClaw.
## Example `openclaw.json`
```json
{
"plugins": {
"slots": {
"contextEngine": "openbrain"
},
"entries": {
"openclaw-openbrain-context": {
"enabled": true,
"config": {
"baseUrl": "https://brain.example.com",
"apiKey": "${OPENBRAIN_API_KEY}",
"source": "openclaw",
"recentMessages": 20,
"semanticSearchLimit": 10,
"subagentRecentMessages": 8
}
}
}
}
}
```
## Development
```bash
pnpm lint
pnpm build
pnpm test
```

View File

@@ -1,58 +0,0 @@
{
"id": "openclaw-openbrain-context",
"name": "OpenBrain Context Engine",
"description": "OpenBrain-backed ContextEngine plugin for OpenClaw",
"version": "0.0.1",
"kind": "context-engine",
"configSchema": {
"type": "object",
"additionalProperties": false,
"required": ["baseUrl", "apiKey"],
"properties": {
"baseUrl": {
"type": "string",
"minLength": 1,
"description": "Base URL of your OpenBrain REST API"
},
"apiKey": {
"type": "string",
"minLength": 1,
"description": "Bearer token used to authenticate against OpenBrain"
},
"source": {
"type": "string",
"minLength": 1,
"default": "openclaw",
"description": "Source prefix stored in OpenBrain (session id is appended)"
},
"recentMessages": {
"type": "integer",
"minimum": 1,
"default": 20,
"description": "How many recent thoughts to fetch during assemble/bootstrap"
},
"semanticSearchLimit": {
"type": "integer",
"minimum": 1,
"default": 10,
"description": "How many semantic matches to request during assemble"
},
"subagentRecentMessages": {
"type": "integer",
"minimum": 1,
"default": 8,
"description": "How many thoughts to use when seeding/summarizing subagents"
}
}
},
"uiHints": {
"baseUrl": {
"label": "OpenBrain Base URL",
"placeholder": "https://brain.example.com"
},
"apiKey": {
"label": "OpenBrain API Key",
"sensitive": true
}
}
}

View File

@@ -1,42 +0,0 @@
{
"name": "@mosaic/openclaw-context",
"version": "0.1.0",
"type": "module",
"description": "OpenClaw → OpenBrain context engine plugin",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist",
"openclaw.plugin.json"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"test": "vitest run"
},
"dependencies": {
"@mosaic/types": "workspace:*"
},
"devDependencies": {
"typescript": "^5",
"vitest": "^2",
"@types/node": "^22"
},
"keywords": [
"openclaw",
"openbrain",
"context-engine",
"plugin"
],
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
"access": "public"
}
}

View File

@@ -1,3 +0,0 @@
export const OPENBRAIN_CONTEXT_ENGINE_ID = "openbrain";
export const OPENBRAIN_PLUGIN_ID = "openclaw-openbrain-context";
export const OPENBRAIN_PLUGIN_VERSION = "0.0.1";

View File

@@ -1,774 +0,0 @@
import { OPENBRAIN_CONTEXT_ENGINE_ID, OPENBRAIN_PLUGIN_VERSION } from "./constants.js";
import { OpenBrainConfigError } from "./errors.js";
import type {
AgentMessage,
AssembleResult,
BootstrapResult,
CompactResult,
ContextEngine,
ContextEngineInfo,
IngestBatchResult,
IngestResult,
PluginLogger,
SubagentEndReason,
SubagentSpawnPreparation,
} from "./openclaw-types.js";
import {
OpenBrainClient,
type OpenBrainClientLike,
type OpenBrainSearchInput,
type OpenBrainThought,
type OpenBrainThoughtMetadata,
} from "./openbrain-client.js";
export type OpenBrainContextEngineConfig = {
baseUrl?: string;
apiKey?: string;
recentMessages?: number;
semanticSearchLimit?: number;
source?: string;
subagentRecentMessages?: number;
};
type ResolvedOpenBrainContextEngineConfig = {
baseUrl: string;
apiKey: string;
recentMessages: number;
semanticSearchLimit: number;
source: string;
subagentRecentMessages: number;
};
export type OpenBrainContextEngineDeps = {
createClient?: (config: ResolvedOpenBrainContextEngineConfig) => OpenBrainClientLike;
now?: () => number;
logger?: PluginLogger;
};
type SubagentState = {
parentSessionKey: string;
seedThoughtId?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function parsePositiveInteger(value: unknown, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
const rounded = Math.floor(value);
return rounded > 0 ? rounded : fallback;
}
function normalizeRole(role: unknown): string {
if (typeof role !== "string" || role.length === 0) {
return "assistant";
}
if (role === "user" || role === "assistant" || role === "tool" || role === "system") {
return role;
}
return "assistant";
}
function serializeContent(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
return value
.map((part) => serializeContent(part))
.filter((part) => part.length > 0)
.join("\n")
.trim();
}
if (isRecord(value) && typeof value.text === "string") {
return value.text;
}
if (value === undefined || value === null) {
return "";
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function estimateTextTokens(text: string): number {
const normalized = text.trim();
if (normalized.length === 0) {
return 1;
}
return Math.max(1, Math.ceil(normalized.length / 4) + 4);
}
function thoughtTimestamp(thought: OpenBrainThought, fallbackTimestamp: number): number {
const createdAt =
thought.createdAt ??
(typeof thought.created_at === "string" ? thought.created_at : undefined);
if (createdAt === undefined) {
return fallbackTimestamp;
}
const parsed = Date.parse(createdAt);
return Number.isFinite(parsed) ? parsed : fallbackTimestamp;
}
function thoughtFingerprint(thought: OpenBrainThought): string {
const role = typeof thought.metadata?.role === "string" ? thought.metadata.role : "assistant";
return `${role}\n${thought.content}`;
}
function truncateLine(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength - 3)}...`;
}
export class OpenBrainContextEngine implements ContextEngine {
readonly info: ContextEngineInfo = {
id: OPENBRAIN_CONTEXT_ENGINE_ID,
name: "OpenBrain Context Engine",
version: OPENBRAIN_PLUGIN_VERSION,
ownsCompaction: true,
};
private readonly rawConfig: unknown;
private readonly createClientFn:
| ((config: ResolvedOpenBrainContextEngineConfig) => OpenBrainClientLike)
| undefined;
private readonly now: () => number;
private readonly logger: PluginLogger | undefined;
private config: ResolvedOpenBrainContextEngineConfig | undefined;
private client: OpenBrainClientLike | undefined;
private readonly sessionTurns = new Map<string, number>();
private readonly subagentState = new Map<string, SubagentState>();
private disposed = false;
constructor(rawConfig: unknown, deps?: OpenBrainContextEngineDeps) {
this.rawConfig = rawConfig;
this.createClientFn = deps?.createClient;
this.now = deps?.now ?? (() => Date.now());
this.logger = deps?.logger;
}
async bootstrap(params: { sessionId: string; sessionFile: string }): Promise<BootstrapResult> {
this.assertNotDisposed();
const config = this.getConfig();
const client = this.getClient();
const source = this.sourceForSession(params.sessionId);
const recentThoughts = await client.listRecent({
limit: config.recentMessages,
source,
});
const sessionThoughts = this.filterSessionThoughts(recentThoughts, params.sessionId);
let maxTurn = -1;
for (const thought of sessionThoughts) {
const turn = thought.metadata?.turn;
if (typeof turn === "number" && Number.isFinite(turn) && turn > maxTurn) {
maxTurn = turn;
}
}
this.sessionTurns.set(params.sessionId, maxTurn + 1);
return {
bootstrapped: true,
importedMessages: sessionThoughts.length,
};
}
async ingest(params: {
sessionId: string;
message: AgentMessage;
isHeartbeat?: boolean;
}): Promise<IngestResult> {
this.assertNotDisposed();
const client = this.getClient();
const content = serializeContent(params.message.content).trim();
if (content.length === 0) {
return { ingested: false };
}
const metadata: OpenBrainThoughtMetadata = {
sessionId: params.sessionId,
turn: this.nextTurn(params.sessionId),
role: normalizeRole(params.message.role),
type: "message",
};
if (params.isHeartbeat === true) {
metadata.isHeartbeat = true;
}
await client.createThought({
content,
source: this.sourceForSession(params.sessionId),
metadata,
});
return { ingested: true };
}
async ingestBatch(params: {
sessionId: string;
messages: AgentMessage[];
isHeartbeat?: boolean;
}): Promise<IngestBatchResult> {
this.assertNotDisposed();
const maxConcurrency = 5;
let ingestedCount = 0;
for (let i = 0; i < params.messages.length; i += maxConcurrency) {
const chunk = params.messages.slice(i, i + maxConcurrency);
const results = await Promise.all(
chunk.map((message) => {
const ingestParams: {
sessionId: string;
message: AgentMessage;
isHeartbeat?: boolean;
} = {
sessionId: params.sessionId,
message,
};
if (params.isHeartbeat !== undefined) {
ingestParams.isHeartbeat = params.isHeartbeat;
}
return this.ingest(ingestParams);
}),
);
for (const result of results) {
if (result.ingested) {
ingestedCount += 1;
}
}
}
return { ingestedCount };
}
async assemble(params: {
sessionId: string;
messages: AgentMessage[];
tokenBudget?: number;
}): Promise<AssembleResult> {
this.assertNotDisposed();
const config = this.getConfig();
const client = this.getClient();
const source = this.sourceForSession(params.sessionId);
const recentThoughts = this.filterSessionThoughts(
await client.listRecent({
limit: config.recentMessages,
source,
}),
params.sessionId,
);
const semanticThoughts = await this.searchSemanticThoughts({
client,
source,
config,
sessionId: params.sessionId,
messages: params.messages,
});
const mergedThoughts = this.mergeThoughts(recentThoughts, semanticThoughts);
const mergedMessages =
mergedThoughts.length > 0
? mergedThoughts.map((thought, index) => this.toAgentMessage(thought, index))
: params.messages;
const tokenBudget = params.tokenBudget;
const budgetedMessages =
typeof tokenBudget === "number" && tokenBudget > 0
? this.trimToBudget(mergedMessages, tokenBudget)
: mergedMessages;
return {
messages: budgetedMessages,
estimatedTokens: this.estimateTokensForMessages(budgetedMessages),
};
}
async compact(params: {
sessionId: string;
sessionFile: string;
tokenBudget?: number;
force?: boolean;
currentTokenCount?: number;
compactionTarget?: "budget" | "threshold";
customInstructions?: string;
legacyParams?: Record<string, unknown>;
}): Promise<CompactResult> {
this.assertNotDisposed();
const config = this.getConfig();
const client = this.getClient();
const source = this.sourceForSession(params.sessionId);
const recentThoughts = this.filterSessionThoughts(
await client.listRecent({
limit: Math.max(config.recentMessages, config.subagentRecentMessages),
source,
}),
params.sessionId,
);
if (recentThoughts.length === 0) {
return {
ok: true,
compacted: false,
reason: "no-session-context",
result: {
tokensBefore: 0,
tokensAfter: 0,
},
};
}
const summarizedThoughts = this.selectSummaryThoughts(recentThoughts);
const summary = this.buildSummary(
params.customInstructions !== undefined
? {
sessionId: params.sessionId,
thoughts: summarizedThoughts,
customInstructions: params.customInstructions,
}
: {
sessionId: params.sessionId,
thoughts: summarizedThoughts,
},
);
const summaryTokens = estimateTextTokens(summary);
const tokensBefore = this.estimateTokensForThoughts(summarizedThoughts);
await client.createThought({
content: summary,
source,
metadata: {
sessionId: params.sessionId,
turn: this.nextTurn(params.sessionId),
role: "assistant",
type: "summary",
},
});
const summaryThoughtIds = Array.from(
new Set(
summarizedThoughts
.map((thought) => thought.id.trim())
.filter((id) => id.length > 0),
),
);
await Promise.all(summaryThoughtIds.map((thoughtId) => client.deleteThought(thoughtId)));
return {
ok: true,
compacted: true,
reason: "summary-archived",
result: {
summary,
tokensBefore,
tokensAfter: summaryTokens,
},
};
}
async prepareSubagentSpawn(params: {
parentSessionKey: string;
childSessionKey: string;
ttlMs?: number;
}): Promise<SubagentSpawnPreparation | undefined> {
this.assertNotDisposed();
const config = this.getConfig();
const client = this.getClient();
const parentThoughts = this.filterSessionThoughts(
await client.listRecent({
limit: config.subagentRecentMessages,
source: this.sourceForSession(params.parentSessionKey),
}),
params.parentSessionKey,
);
const seedContent = this.buildSubagentSeedContent({
parentSessionKey: params.parentSessionKey,
childSessionKey: params.childSessionKey,
thoughts: parentThoughts,
});
const createdThought = await client.createThought({
content: seedContent,
source: this.sourceForSession(params.childSessionKey),
metadata: {
sessionId: params.childSessionKey,
role: "assistant",
type: "summary",
parentSessionId: params.parentSessionKey,
ttlMs: params.ttlMs,
},
});
this.subagentState.set(params.childSessionKey, {
parentSessionKey: params.parentSessionKey,
seedThoughtId: createdThought.id,
});
return {
rollback: async () => {
const state = this.subagentState.get(params.childSessionKey);
this.subagentState.delete(params.childSessionKey);
if (state?.seedThoughtId !== undefined && state.seedThoughtId.length > 0) {
await client.deleteThought(state.seedThoughtId);
}
},
};
}
async onSubagentEnded(params: {
childSessionKey: string;
reason: SubagentEndReason;
}): Promise<void> {
this.assertNotDisposed();
const state = this.subagentState.get(params.childSessionKey);
if (state === undefined) {
return;
}
const client = this.getClient();
const config = this.getConfig();
const childThoughts = this.filterSessionThoughts(
await client.listRecent({
limit: config.subagentRecentMessages,
source: this.sourceForSession(params.childSessionKey),
}),
params.childSessionKey,
);
const summary = this.buildSubagentResultSummary({
childSessionKey: params.childSessionKey,
reason: params.reason,
thoughts: childThoughts,
});
await client.createThought({
content: summary,
source: this.sourceForSession(state.parentSessionKey),
metadata: {
sessionId: state.parentSessionKey,
turn: this.nextTurn(state.parentSessionKey),
role: "tool",
type: "subagent-result",
childSessionId: params.childSessionKey,
reason: params.reason,
},
});
this.subagentState.delete(params.childSessionKey);
}
async dispose(): Promise<void> {
this.sessionTurns.clear();
this.subagentState.clear();
this.disposed = true;
}
private searchSemanticThoughts(params: {
client: OpenBrainClientLike;
source: string;
config: ResolvedOpenBrainContextEngineConfig;
sessionId: string;
messages: AgentMessage[];
}): Promise<OpenBrainThought[]> {
const query = this.pickSemanticQuery(params.messages);
if (query === undefined || query.length === 0 || params.config.semanticSearchLimit <= 0) {
return Promise.resolve([]);
}
const request: OpenBrainSearchInput = {
query,
limit: params.config.semanticSearchLimit,
source: params.source,
};
return params.client
.search(request)
.then((results) => this.filterSessionThoughts(results, params.sessionId))
.catch((error) => {
this.logger?.warn?.("OpenBrain semantic search failed", error);
return [];
});
}
private pickSemanticQuery(messages: AgentMessage[]): string | undefined {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message === undefined) {
continue;
}
if (normalizeRole(message.role) !== "user") {
continue;
}
const content = serializeContent(message.content).trim();
if (content.length > 0) {
return content;
}
}
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message === undefined) {
continue;
}
const content = serializeContent(message.content).trim();
if (content.length > 0) {
return content;
}
}
return undefined;
}
private mergeThoughts(recentThoughts: OpenBrainThought[], semanticThoughts: OpenBrainThought[]): OpenBrainThought[] {
const merged: OpenBrainThought[] = [];
const seenIds = new Set<string>();
const seenFingerprints = new Set<string>();
for (const thought of [...recentThoughts, ...semanticThoughts]) {
const id = thought.id.trim();
const fingerprint = thoughtFingerprint(thought);
if (id.length > 0 && seenIds.has(id)) {
continue;
}
if (seenFingerprints.has(fingerprint)) {
continue;
}
if (id.length > 0) {
seenIds.add(id);
}
seenFingerprints.add(fingerprint);
merged.push(thought);
}
return merged;
}
private filterSessionThoughts(thoughts: OpenBrainThought[], sessionId: string): OpenBrainThought[] {
return thoughts.filter((thought) => {
const thoughtSessionId = thought.metadata?.sessionId;
if (typeof thoughtSessionId === "string" && thoughtSessionId.length > 0) {
return thoughtSessionId === sessionId;
}
return thought.source === this.sourceForSession(sessionId);
});
}
private toAgentMessage(thought: OpenBrainThought, index: number): AgentMessage {
return {
role: normalizeRole(thought.metadata?.role),
content: thought.content,
timestamp: thoughtTimestamp(thought, this.now() + index),
};
}
private trimToBudget(messages: AgentMessage[], tokenBudget: number): AgentMessage[] {
if (messages.length === 0 || tokenBudget <= 0) {
return [];
}
let total = 0;
const budgeted: AgentMessage[] = [];
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message === undefined) {
continue;
}
const tokens = estimateTextTokens(serializeContent(message.content));
if (total + tokens > tokenBudget) {
break;
}
total += tokens;
budgeted.unshift(message);
}
if (budgeted.length === 0) {
const lastMessage = messages[messages.length - 1];
return lastMessage === undefined ? [] : [lastMessage];
}
return budgeted;
}
private estimateTokensForMessages(messages: AgentMessage[]): number {
return messages.reduce((total, message) => {
return total + estimateTextTokens(serializeContent(message.content));
}, 0);
}
private estimateTokensForThoughts(thoughts: OpenBrainThought[]): number {
return thoughts.reduce((total, thought) => total + estimateTextTokens(thought.content), 0);
}
private buildSummary(params: {
sessionId: string;
thoughts: OpenBrainThought[];
customInstructions?: string;
}): string {
const lines = params.thoughts.map((thought) => {
const role = normalizeRole(thought.metadata?.role);
const content = truncateLine(thought.content.replace(/\s+/g, " ").trim(), 180);
return `- ${role}: ${content}`;
});
const header = `Context summary for session ${params.sessionId}`;
const instruction =
params.customInstructions !== undefined && params.customInstructions.trim().length > 0
? `Custom instructions: ${params.customInstructions.trim()}\n`
: "";
return `${header}\n${instruction}${lines.join("\n")}`;
}
private selectSummaryThoughts(thoughts: OpenBrainThought[]): OpenBrainThought[] {
const ordered = [...thoughts].sort((a, b) => {
return thoughtTimestamp(a, 0) - thoughtTimestamp(b, 0);
});
const maxLines = Math.min(ordered.length, 10);
return ordered.slice(Math.max(ordered.length - maxLines, 0));
}
private buildSubagentSeedContent(params: {
parentSessionKey: string;
childSessionKey: string;
thoughts: OpenBrainThought[];
}): string {
const lines = params.thoughts.slice(-5).map((thought) => {
const role = normalizeRole(thought.metadata?.role);
return `- ${role}: ${truncateLine(thought.content.replace(/\s+/g, " ").trim(), 160)}`;
});
const contextBlock = lines.length > 0 ? lines.join("\n") : "- (no prior context found)";
return [
`Subagent context seed`,
`Parent session: ${params.parentSessionKey}`,
`Child session: ${params.childSessionKey}`,
contextBlock,
].join("\n");
}
private buildSubagentResultSummary(params: {
childSessionKey: string;
reason: SubagentEndReason;
thoughts: OpenBrainThought[];
}): string {
const lines = params.thoughts.slice(-5).map((thought) => {
const role = normalizeRole(thought.metadata?.role);
return `- ${role}: ${truncateLine(thought.content.replace(/\s+/g, " ").trim(), 160)}`;
});
const contextBlock = lines.length > 0 ? lines.join("\n") : "- (no child messages found)";
return [
`Subagent ended (${params.reason})`,
`Child session: ${params.childSessionKey}`,
contextBlock,
].join("\n");
}
private sourceForSession(sessionId: string): string {
return `${this.getConfig().source}:${sessionId}`;
}
private nextTurn(sessionId: string): number {
const next = this.sessionTurns.get(sessionId) ?? 0;
this.sessionTurns.set(sessionId, next + 1);
return next;
}
private getClient(): OpenBrainClientLike {
if (this.client !== undefined) {
return this.client;
}
const config = this.getConfig();
this.client =
this.createClientFn?.(config) ??
new OpenBrainClient({
baseUrl: config.baseUrl,
apiKey: config.apiKey,
});
return this.client;
}
private getConfig(): ResolvedOpenBrainContextEngineConfig {
if (this.config !== undefined) {
return this.config;
}
const raw = isRecord(this.rawConfig) ? this.rawConfig : {};
const baseUrl = typeof raw.baseUrl === "string" ? raw.baseUrl.trim() : "";
if (baseUrl.length === 0) {
throw new OpenBrainConfigError("Missing required OpenBrain config: baseUrl");
}
const apiKey = typeof raw.apiKey === "string" ? raw.apiKey.trim() : "";
if (apiKey.length === 0) {
throw new OpenBrainConfigError("Missing required OpenBrain config: apiKey");
}
this.config = {
baseUrl,
apiKey,
recentMessages: parsePositiveInteger(raw.recentMessages, 20),
semanticSearchLimit: parsePositiveInteger(raw.semanticSearchLimit, 10),
source: typeof raw.source === "string" && raw.source.trim().length > 0 ? raw.source.trim() : "openclaw",
subagentRecentMessages: parsePositiveInteger(raw.subagentRecentMessages, 8),
};
return this.config;
}
private assertNotDisposed(): void {
if (this.disposed) {
throw new Error("OpenBrainContextEngine has already been disposed");
}
}
}

View File

@@ -1,40 +0,0 @@
export class OpenBrainError extends Error {
constructor(message: string, cause?: unknown) {
super(message);
this.name = "OpenBrainError";
if (cause !== undefined) {
(this as Error & { cause?: unknown }).cause = cause;
}
}
}
export class OpenBrainConfigError extends OpenBrainError {
constructor(message: string) {
super(message);
this.name = "OpenBrainConfigError";
}
}
export class OpenBrainHttpError extends OpenBrainError {
readonly status: number;
readonly endpoint: string;
readonly responseBody: string | undefined;
constructor(params: { endpoint: string; status: number; responseBody: string | undefined }) {
super(`OpenBrain request failed (${params.status}) for ${params.endpoint}`);
this.name = "OpenBrainHttpError";
this.status = params.status;
this.endpoint = params.endpoint;
this.responseBody = params.responseBody;
}
}
export class OpenBrainRequestError extends OpenBrainError {
readonly endpoint: string;
constructor(params: { endpoint: string; cause: unknown }) {
super(`OpenBrain request failed for ${params.endpoint}`, params.cause);
this.name = "OpenBrainRequestError";
this.endpoint = params.endpoint;
}
}

View File

@@ -1,31 +0,0 @@
import {
OPENBRAIN_CONTEXT_ENGINE_ID,
OPENBRAIN_PLUGIN_ID,
OPENBRAIN_PLUGIN_VERSION,
} from "./constants.js";
import { OpenBrainContextEngine } from "./engine.js";
import type { OpenClawPluginApi } from "./openclaw-types.js";
export { OPENBRAIN_CONTEXT_ENGINE_ID } from "./constants.js";
export { OpenBrainContextEngine } from "./engine.js";
export { OpenBrainConfigError, OpenBrainHttpError, OpenBrainRequestError } from "./errors.js";
export { OpenBrainClient } from "./openbrain-client.js";
export type { OpenBrainContextEngineConfig } from "./engine.js";
export type { OpenClawPluginApi } from "./openclaw-types.js";
export function register(api: OpenClawPluginApi): void {
api.registerContextEngine(OPENBRAIN_CONTEXT_ENGINE_ID, () => {
const deps = api.logger !== undefined ? { logger: api.logger } : undefined;
return new OpenBrainContextEngine(api.pluginConfig, deps);
});
}
const plugin = {
id: OPENBRAIN_PLUGIN_ID,
name: "OpenBrain Context Engine",
version: OPENBRAIN_PLUGIN_VERSION,
kind: "context-engine",
register,
};
export default plugin;

View File

@@ -1,333 +0,0 @@
import { OpenBrainConfigError, OpenBrainHttpError, OpenBrainRequestError } from "./errors.js";
export type OpenBrainThoughtMetadata = Record<string, unknown> & {
sessionId?: string;
turn?: number;
role?: string;
type?: string;
};
export type OpenBrainThought = {
id: string;
content: string;
source: string;
metadata: OpenBrainThoughtMetadata | undefined;
createdAt: string | undefined;
updatedAt: string | undefined;
score: number | undefined;
[key: string]: unknown;
};
export type OpenBrainThoughtInput = {
content: string;
source: string;
metadata?: OpenBrainThoughtMetadata;
};
export type OpenBrainSearchInput = {
query: string;
limit: number;
source?: string;
};
export type OpenBrainClientOptions = {
baseUrl: string;
apiKey: string;
fetchImpl?: typeof fetch;
};
export interface OpenBrainClientLike {
createThought(input: OpenBrainThoughtInput): Promise<OpenBrainThought>;
search(input: OpenBrainSearchInput): Promise<OpenBrainThought[]>;
listRecent(input: { limit: number; source?: string }): Promise<OpenBrainThought[]>;
updateThought(
id: string,
payload: { content?: string; metadata?: OpenBrainThoughtMetadata },
): Promise<OpenBrainThought>;
deleteThought(id: string): Promise<void>;
deleteThoughts(params: { source?: string; metadataId?: string }): Promise<void>;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readString(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return typeof value === "string" ? value : undefined;
}
function readNumber(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
return typeof value === "number" ? value : undefined;
}
function normalizeBaseUrl(baseUrl: string): string {
const normalized = baseUrl.trim().replace(/\/+$/, "");
if (normalized.length === 0) {
throw new OpenBrainConfigError("Missing required OpenBrain config: baseUrl");
}
return normalized;
}
function normalizeApiKey(apiKey: string): string {
const normalized = apiKey.trim();
if (normalized.length === 0) {
throw new OpenBrainConfigError("Missing required OpenBrain config: apiKey");
}
return normalized;
}
function normalizeHeaders(headers: unknown): Record<string, string> {
if (headers === undefined) {
return {};
}
if (Array.isArray(headers)) {
const normalized: Record<string, string> = {};
for (const pair of headers) {
if (!Array.isArray(pair) || pair.length < 2) {
continue;
}
const key = pair[0];
const value = pair[1];
if (typeof key !== "string" || typeof value !== "string") {
continue;
}
normalized[key] = value;
}
return normalized;
}
if (headers instanceof Headers) {
const normalized: Record<string, string> = {};
for (const [key, value] of headers.entries()) {
normalized[key] = value;
}
return normalized;
}
if (!isRecord(headers)) {
return {};
}
const normalized: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
if (typeof value === "string") {
normalized[key] = value;
continue;
}
if (Array.isArray(value)) {
normalized[key] = value.join(", ");
}
}
return normalized;
}
async function readResponseBody(response: Response): Promise<string | undefined> {
try {
const body = await response.text();
return body.length > 0 ? body : undefined;
} catch {
return undefined;
}
}
export class OpenBrainClient implements OpenBrainClientLike {
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly fetchImpl: typeof fetch;
constructor(options: OpenBrainClientOptions) {
this.baseUrl = normalizeBaseUrl(options.baseUrl);
this.apiKey = normalizeApiKey(options.apiKey);
this.fetchImpl = options.fetchImpl ?? fetch;
}
async createThought(input: OpenBrainThoughtInput): Promise<OpenBrainThought> {
const payload = await this.request<unknown>("/v1/thoughts", {
method: "POST",
body: JSON.stringify(input),
});
return this.extractThought(payload);
}
async search(input: OpenBrainSearchInput): Promise<OpenBrainThought[]> {
const payload = await this.request<unknown>("/v1/search", {
method: "POST",
body: JSON.stringify(input),
});
return this.extractThoughtArray(payload);
}
async listRecent(input: { limit: number; source?: string }): Promise<OpenBrainThought[]> {
const params = new URLSearchParams({
limit: String(input.limit),
});
if (input.source !== undefined && input.source.length > 0) {
params.set("source", input.source);
}
const payload = await this.request<unknown>(`/v1/thoughts/recent?${params.toString()}`, {
method: "GET",
});
return this.extractThoughtArray(payload);
}
async updateThought(
id: string,
payload: { content?: string; metadata?: OpenBrainThoughtMetadata },
): Promise<OpenBrainThought> {
const responsePayload = await this.request<unknown>(`/v1/thoughts/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
return this.extractThought(responsePayload);
}
async deleteThought(id: string): Promise<void> {
await this.request<unknown>(`/v1/thoughts/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
async deleteThoughts(params: { source?: string; metadataId?: string }): Promise<void> {
const query = new URLSearchParams();
if (params.source !== undefined && params.source.length > 0) {
query.set("source", params.source);
}
if (params.metadataId !== undefined && params.metadataId.length > 0) {
query.set("metadata_id", params.metadataId);
}
const suffix = query.size > 0 ? `?${query.toString()}` : "";
await this.request<unknown>(`/v1/thoughts${suffix}`, {
method: "DELETE",
});
}
private async request<T>(endpoint: string, init: RequestInit): Promise<T> {
const headers = normalizeHeaders(init.headers);
headers.Authorization = `Bearer ${this.apiKey}`;
if (init.body !== undefined && headers["Content-Type"] === undefined) {
headers["Content-Type"] = "application/json";
}
const url = `${this.baseUrl}${endpoint}`;
let response: Response;
try {
response = await this.fetchImpl(url, {
...init,
headers,
});
} catch (error) {
throw new OpenBrainRequestError({ endpoint, cause: error });
}
if (!response.ok) {
throw new OpenBrainHttpError({
endpoint,
status: response.status,
responseBody: await readResponseBody(response),
});
}
if (response.status === 204) {
return undefined as T;
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) {
return undefined as T;
}
return (await response.json()) as T;
}
private extractThoughtArray(payload: unknown): OpenBrainThought[] {
if (Array.isArray(payload)) {
return payload.map((item) => this.normalizeThought(item));
}
if (!isRecord(payload)) {
return [];
}
const candidates = [payload.thoughts, payload.data, payload.results, payload.items];
for (const candidate of candidates) {
if (Array.isArray(candidate)) {
return candidate.map((item) => this.normalizeThought(item));
}
}
return [];
}
private extractThought(payload: unknown): OpenBrainThought {
if (isRecord(payload)) {
const nested = payload.thought;
if (nested !== undefined) {
return this.normalizeThought(nested);
}
const data = payload.data;
if (data !== undefined && !Array.isArray(data)) {
return this.normalizeThought(data);
}
}
return this.normalizeThought(payload);
}
private normalizeThought(value: unknown): OpenBrainThought {
if (!isRecord(value)) {
return {
id: "",
content: "",
source: "",
metadata: undefined,
createdAt: undefined,
updatedAt: undefined,
score: undefined,
};
}
const metadataValue = value.metadata;
const metadata = isRecord(metadataValue)
? ({ ...metadataValue } as OpenBrainThoughtMetadata)
: undefined;
const id = readString(value, "id") ?? readString(value, "thought_id") ?? "";
const content =
readString(value, "content") ??
readString(value, "text") ??
(value.content === undefined ? "" : String(value.content));
const source = readString(value, "source") ?? "";
const createdAt = readString(value, "createdAt") ?? readString(value, "created_at");
const updatedAt = readString(value, "updatedAt") ?? readString(value, "updated_at");
const score = readNumber(value, "score");
return {
...value,
id,
content,
source,
metadata,
createdAt,
updatedAt,
score,
};
}
}
export { normalizeApiKey, normalizeBaseUrl };

View File

@@ -1,128 +0,0 @@
export type AgentMessageRole = "user" | "assistant" | "tool" | "system" | string;
export type AgentMessage = {
role: AgentMessageRole;
content: unknown;
timestamp?: number;
[key: string]: unknown;
};
export type AssembleResult = {
messages: AgentMessage[];
estimatedTokens: number;
systemPromptAddition?: string;
};
export type CompactResult = {
ok: boolean;
compacted: boolean;
reason?: string;
result?: {
summary?: string;
firstKeptEntryId?: string;
tokensBefore: number;
tokensAfter?: number;
details?: unknown;
};
};
export type IngestResult = {
ingested: boolean;
};
export type IngestBatchResult = {
ingestedCount: number;
};
export type BootstrapResult = {
bootstrapped: boolean;
importedMessages?: number;
reason?: string;
};
export type ContextEngineInfo = {
id: string;
name: string;
version?: string;
ownsCompaction?: boolean;
};
export type SubagentSpawnPreparation = {
rollback: () => void | Promise<void>;
};
export type SubagentEndReason = "deleted" | "completed" | "swept" | "released";
export interface ContextEngine {
readonly info: ContextEngineInfo;
bootstrap?(params: { sessionId: string; sessionFile: string }): Promise<BootstrapResult>;
ingest(params: {
sessionId: string;
message: AgentMessage;
isHeartbeat?: boolean;
}): Promise<IngestResult>;
ingestBatch?(params: {
sessionId: string;
messages: AgentMessage[];
isHeartbeat?: boolean;
}): Promise<IngestBatchResult>;
afterTurn?(params: {
sessionId: string;
sessionFile: string;
messages: AgentMessage[];
prePromptMessageCount: number;
autoCompactionSummary?: string;
isHeartbeat?: boolean;
tokenBudget?: number;
legacyCompactionParams?: Record<string, unknown>;
}): Promise<void>;
assemble(params: {
sessionId: string;
messages: AgentMessage[];
tokenBudget?: number;
}): Promise<AssembleResult>;
compact(params: {
sessionId: string;
sessionFile: string;
tokenBudget?: number;
force?: boolean;
currentTokenCount?: number;
compactionTarget?: "budget" | "threshold";
customInstructions?: string;
legacyParams?: Record<string, unknown>;
}): Promise<CompactResult>;
prepareSubagentSpawn?(params: {
parentSessionKey: string;
childSessionKey: string;
ttlMs?: number;
}): Promise<SubagentSpawnPreparation | undefined>;
onSubagentEnded?(params: {
childSessionKey: string;
reason: SubagentEndReason;
}): Promise<void>;
dispose?(): Promise<void>;
}
export type ContextEngineFactory = () => ContextEngine | Promise<ContextEngine>;
export type PluginLogger = {
debug?: (...args: unknown[]) => void;
info?: (...args: unknown[]) => void;
warn?: (...args: unknown[]) => void;
error?: (...args: unknown[]) => void;
};
export type OpenClawPluginApi = {
pluginConfig?: Record<string, unknown>;
logger?: PluginLogger;
registerContextEngine: (id: string, factory: ContextEngineFactory) => void;
};

View File

@@ -1,414 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { OpenBrainConfigError } from "../src/errors.js";
import { OpenBrainContextEngine } from "../src/engine.js";
import type { AgentMessage } from "../src/openclaw-types.js";
import type {
OpenBrainClientLike,
OpenBrainThought,
OpenBrainThoughtInput,
} from "../src/openbrain-client.js";
function makeThought(
id: string,
content: string,
sessionId: string,
role: string,
createdAt: string,
): OpenBrainThought {
return {
id,
content,
source: `openclaw:${sessionId}`,
metadata: {
sessionId,
role,
type: "message",
},
createdAt,
updatedAt: undefined,
score: undefined,
};
}
function makeMockClient(): OpenBrainClientLike {
return {
createThought: vi.fn(async (input: OpenBrainThoughtInput) => ({
id: `thought-${Math.random().toString(36).slice(2)}`,
content: input.content,
source: input.source,
metadata: input.metadata,
createdAt: new Date().toISOString(),
updatedAt: undefined,
score: undefined,
})),
search: vi.fn(async () => []),
listRecent: vi.fn(async () => []),
updateThought: vi.fn(async (id, payload) => ({
id,
content: payload.content ?? "",
source: "openclaw:session",
metadata: payload.metadata,
createdAt: new Date().toISOString(),
updatedAt: undefined,
score: undefined,
})),
deleteThought: vi.fn(async () => undefined),
deleteThoughts: vi.fn(async () => undefined),
};
}
const sessionId = "session-main";
const userMessage: AgentMessage = {
role: "user",
content: "What did we decide yesterday?",
timestamp: Date.now(),
};
describe("OpenBrainContextEngine", () => {
it("throws OpenBrainConfigError at bootstrap when baseUrl/apiKey are missing", async () => {
const engine = new OpenBrainContextEngine({});
await expect(
engine.bootstrap({
sessionId,
sessionFile: "/tmp/session.json",
}),
).rejects.toBeInstanceOf(OpenBrainConfigError);
});
it("ingests messages with session metadata", async () => {
const mockClient = makeMockClient();
const engine = new OpenBrainContextEngine(
{
baseUrl: "https://brain.example.com",
apiKey: "secret",
source: "openclaw",
},
{
createClient: () => mockClient,
},
);
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
const result = await engine.ingest({ sessionId, message: userMessage });
expect(result.ingested).toBe(true);
expect(mockClient.createThought).toHaveBeenCalledWith(
expect.objectContaining({
source: "openclaw:session-main",
metadata: expect.objectContaining({
sessionId,
role: "user",
type: "message",
turn: 0,
}),
}),
);
});
it("ingests batches and returns ingested count", async () => {
const mockClient = makeMockClient();
const engine = new OpenBrainContextEngine(
{
baseUrl: "https://brain.example.com",
apiKey: "secret",
},
{ createClient: () => mockClient },
);
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
const result = await engine.ingestBatch({
sessionId,
messages: [
{ role: "user", content: "one", timestamp: 1 },
{ role: "assistant", content: "two", timestamp: 2 },
],
});
expect(result.ingestedCount).toBe(2);
expect(mockClient.createThought).toHaveBeenCalledTimes(2);
});
it("ingests batches in parallel chunks of five", async () => {
const mockClient = makeMockClient();
let inFlight = 0;
let maxInFlight = 0;
let createdCount = 0;
vi.mocked(mockClient.createThought).mockImplementation(async (input: OpenBrainThoughtInput) => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
inFlight -= 1;
createdCount += 1;
return {
id: `thought-${createdCount}`,
content: input.content,
source: input.source,
metadata: input.metadata,
createdAt: new Date().toISOString(),
updatedAt: undefined,
score: undefined,
};
});
const engine = new OpenBrainContextEngine(
{
baseUrl: "https://brain.example.com",
apiKey: "secret",
},
{ createClient: () => mockClient },
);
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
const result = await engine.ingestBatch({
sessionId,
messages: Array.from({ length: 10 }, (_, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index + 1}`,
timestamp: index + 1,
})),
});
expect(result.ingestedCount).toBe(10);
expect(maxInFlight).toBe(5);
expect(mockClient.createThought).toHaveBeenCalledTimes(10);
});
it("assembles context from recent + semantic search, deduped and budget-aware", async () => {
const mockClient = makeMockClient();
vi.mocked(mockClient.listRecent).mockResolvedValue([
makeThought("t1", "recent user context", sessionId, "user", "2026-03-06T12:00:00.000Z"),
makeThought(
"t2",
"recent assistant context",
sessionId,
"assistant",
"2026-03-06T12:01:00.000Z",
),
]);
vi.mocked(mockClient.search).mockResolvedValue([
makeThought(
"t2",
"recent assistant context",
sessionId,
"assistant",
"2026-03-06T12:01:00.000Z",
),
makeThought("t3", "semantic match", sessionId, "assistant", "2026-03-06T12:02:00.000Z"),
]);
const engine = new OpenBrainContextEngine(
{
baseUrl: "https://brain.example.com",
apiKey: "secret",
recentMessages: 10,
semanticSearchLimit: 10,
},
{ createClient: () => mockClient },
);
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
const result = await engine.assemble({
sessionId,
messages: [
{
role: "user",
content: "Find the semantic context",
timestamp: Date.now(),
},
],
tokenBudget: 40,
});
expect(mockClient.search).toHaveBeenCalledWith(
expect.objectContaining({
query: "Find the semantic context",
limit: 10,
}),
);
expect(result.estimatedTokens).toBeLessThanOrEqual(40);
expect(result.messages.map((message) => String(message.content))).toEqual([
"recent user context",
"recent assistant context",
"semantic match",
]);
});
it("compact archives a summary thought and deletes summarized inputs", async () => {
const mockClient = makeMockClient();
vi.mocked(mockClient.listRecent).mockResolvedValue(
Array.from({ length: 12 }, (_, index) => {
return makeThought(
`t${index + 1}`,
`message ${index + 1}`,
sessionId,
index % 2 === 0 ? "user" : "assistant",
`2026-03-06T12:${String(index).padStart(2, "0")}:00.000Z`,
);
}),
);
const engine = new OpenBrainContextEngine(
{
baseUrl: "https://brain.example.com",
apiKey: "secret",
},
{ createClient: () => mockClient },
);
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
const result = await engine.compact({
sessionId,
sessionFile: "/tmp/session.json",
tokenBudget: 128,
});
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(mockClient.createThought).toHaveBeenCalledWith(
expect.objectContaining({
source: "openclaw:session-main",
metadata: expect.objectContaining({
sessionId,
type: "summary",
}),
}),
);
const deletedIds = vi
.mocked(mockClient.deleteThought)
.mock.calls.map(([id]) => id)
.sort((left, right) => left.localeCompare(right));
expect(deletedIds).toEqual([
"t10",
"t11",
"t12",
"t3",
"t4",
"t5",
"t6",
"t7",
"t8",
"t9",
]);
});
it("stops trimming once the newest message exceeds budget", async () => {
const mockClient = makeMockClient();
const oversizedNewest = "z".repeat(400);
vi.mocked(mockClient.listRecent).mockResolvedValue([
makeThought("t1", "small older message", sessionId, "assistant", "2026-03-06T12:00:00.000Z"),
makeThought("t2", oversizedNewest, sessionId, "assistant", "2026-03-06T12:01:00.000Z"),
]);
const engine = new OpenBrainContextEngine(
{
baseUrl: "https://brain.example.com",
apiKey: "secret",
},
{ createClient: () => mockClient },
);
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
const result = await engine.assemble({
sessionId,
messages: [
{
role: "user",
content: "query",
timestamp: Date.now(),
},
],
tokenBudget: 12,
});
expect(result.messages.map((message) => String(message.content))).toEqual([oversizedNewest]);
});
it("prepares subagent spawn and rollback deletes seeded context", async () => {
const mockClient = makeMockClient();
vi.mocked(mockClient.listRecent).mockResolvedValue([
makeThought("t1", "parent context", sessionId, "assistant", "2026-03-06T12:00:00.000Z"),
]);
vi.mocked(mockClient.createThought).mockResolvedValue({
id: "seed-thought",
content: "seed",
source: "openclaw:child",
metadata: undefined,
createdAt: "2026-03-06T12:01:00.000Z",
updatedAt: undefined,
score: undefined,
});
const engine = new OpenBrainContextEngine(
{
baseUrl: "https://brain.example.com",
apiKey: "secret",
},
{ createClient: () => mockClient },
);
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
const prep = await engine.prepareSubagentSpawn({
parentSessionKey: sessionId,
childSessionKey: "child-session",
});
expect(prep).toBeDefined();
expect(mockClient.createThought).toHaveBeenCalledWith(
expect.objectContaining({
source: "openclaw:child-session",
}),
);
await prep?.rollback();
expect(mockClient.deleteThought).toHaveBeenCalledWith("seed-thought");
});
it("stores child outcome back into parent on subagent end", async () => {
const mockClient = makeMockClient();
vi.mocked(mockClient.listRecent)
.mockResolvedValueOnce([
makeThought("p1", "parent context", sessionId, "assistant", "2026-03-06T12:00:00.000Z"),
])
.mockResolvedValueOnce([
makeThought("c1", "child result detail", "child-session", "assistant", "2026-03-06T12:05:00.000Z"),
]);
const engine = new OpenBrainContextEngine(
{
baseUrl: "https://brain.example.com",
apiKey: "secret",
},
{ createClient: () => mockClient },
);
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
await engine.prepareSubagentSpawn({
parentSessionKey: sessionId,
childSessionKey: "child-session",
});
await engine.onSubagentEnded({
childSessionKey: "child-session",
reason: "completed",
});
expect(mockClient.createThought).toHaveBeenLastCalledWith(
expect.objectContaining({
source: "openclaw:session-main",
metadata: expect.objectContaining({
type: "subagent-result",
sessionId,
}),
}),
);
});
});

View File

@@ -1,81 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { OpenBrainConfigError, OpenBrainHttpError } from "../src/errors.js";
import { OpenBrainClient } from "../src/openbrain-client.js";
function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: init?.status ?? 200,
headers: {
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
}
describe("OpenBrainClient", () => {
it("sends bearer auth and normalized URL for createThought", async () => {
const fetchMock = vi.fn(async () =>
jsonResponse({
id: "thought-1",
content: "hello",
source: "openclaw:main",
}),
);
const client = new OpenBrainClient({
baseUrl: "https://brain.example.com/",
apiKey: "secret",
fetchImpl: fetchMock as unknown as typeof fetch,
});
await client.createThought({
content: "hello",
source: "openclaw:main",
metadata: { sessionId: "session-1" },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const firstCall = fetchMock.mock.calls[0];
expect(firstCall).toBeDefined();
if (firstCall === undefined) {
throw new Error("Expected fetch call arguments");
}
const [url, init] = firstCall as unknown as [string, RequestInit];
expect(url).toBe("https://brain.example.com/v1/thoughts");
expect(init.method).toBe("POST");
expect(init.headers).toMatchObject({
Authorization: "Bearer secret",
"Content-Type": "application/json",
});
});
it("throws OpenBrainHttpError on non-2xx responses", async () => {
const fetchMock = vi.fn(async () =>
jsonResponse({ error: "unauthorized" }, { status: 401 }),
);
const client = new OpenBrainClient({
baseUrl: "https://brain.example.com",
apiKey: "secret",
fetchImpl: fetchMock as unknown as typeof fetch,
});
await expect(client.listRecent({ limit: 5, source: "openclaw:main" })).rejects.toBeInstanceOf(
OpenBrainHttpError,
);
await expect(client.listRecent({ limit: 5, source: "openclaw:main" })).rejects.toMatchObject({
status: 401,
});
});
it("throws OpenBrainConfigError when initialized without baseUrl or apiKey", () => {
expect(
() => new OpenBrainClient({ baseUrl: "", apiKey: "secret", fetchImpl: fetch }),
).toThrow(OpenBrainConfigError);
expect(
() => new OpenBrainClient({ baseUrl: "https://brain.example.com", apiKey: "", fetchImpl: fetch }),
).toThrow(OpenBrainConfigError);
});
});

View File

@@ -1,30 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { OPENBRAIN_CONTEXT_ENGINE_ID, register } from "../src/index.js";
describe("plugin register()", () => {
it("registers the openbrain context engine factory", async () => {
const registerContextEngine = vi.fn();
register({
registerContextEngine,
pluginConfig: {
baseUrl: "https://brain.example.com",
apiKey: "secret",
},
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
});
expect(registerContextEngine).toHaveBeenCalledTimes(1);
const [id, factory] = registerContextEngine.mock.calls[0] as [string, () => Promise<unknown> | unknown];
expect(id).toBe(OPENBRAIN_CONTEXT_ENGINE_ID);
const engine = await factory();
expect(engine).toHaveProperty("info.id", OPENBRAIN_CONTEXT_ENGINE_ID);
});
});

View File

@@ -1,9 +0,0 @@
import { describe, expect, it } from "vitest";
import { OPENBRAIN_CONTEXT_ENGINE_ID } from "../src/index.js";
describe("project scaffold", () => {
it("exports openbrain context engine id", () => {
expect(OPENBRAIN_CONTEXT_ENGINE_ID).toBe("openbrain");
});
});

View File

@@ -1,24 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": ".",
"types": ["node", "vitest/globals"],
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

1895
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff