Compare commits
4 Commits
feat/regis
...
feat/wave3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0193861784 | |||
| 7f7109fc09 | |||
| 2fca61fe04 | |||
| e8440b9951 |
8
.changeset/README.md
Normal file
8
.changeset/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# 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).
|
||||||
11
.changeset/config.json
Normal file
11
.changeset/config.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$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": []
|
||||||
|
}
|
||||||
7
.changeset/initial-release.md
Normal file
7
.changeset/initial-release.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@mosaic/types": minor
|
||||||
|
"@mosaic/queue": minor
|
||||||
|
"@mosaic/openclaw-context": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Initial release of the @mosaic/* monorepo packages.
|
||||||
1
.npmrc
1
.npmrc
@@ -1 +1,2 @@
|
|||||||
@mosaic:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm
|
@mosaic:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm
|
||||||
|
//git.mosaicstack.dev/api/packages/mosaic/npm/:_authToken=${GITEA_NPM_TOKEN}
|
||||||
|
|||||||
@@ -33,3 +33,17 @@ steps:
|
|||||||
image: valkey/valkey:8-alpine
|
image: valkey/valkey:8-alpine
|
||||||
environment:
|
environment:
|
||||||
- ALLOW_EMPTY_PASSWORD=yes
|
- 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
|
||||||
|
|||||||
5
packages/cli/bin/mosaic.ts
Normal file
5
packages/cli/bin/mosaic.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { rootCommand } from '../src/root-command.js';
|
||||||
|
|
||||||
|
rootCommand.parseAsync(process.argv);
|
||||||
15
packages/cli/eslint.config.js
Normal file
15
packages/cli/eslint.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import tsParser from '@typescript-eslint/parser';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
files: ['src/**/*.ts', 'bin/**/*.ts', 'tests/**/*.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
33
packages/cli/package.json
Normal file
33
packages/cli/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@mosaic/cli",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Mosaic unified CLI — the mosaic command",
|
||||||
|
"bin": {
|
||||||
|
"mosaic": "./dist/bin/mosaic.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mosaic/types": "workspace:*",
|
||||||
|
"@mosaic/coord": "workspace:*",
|
||||||
|
"@mosaic/queue": "workspace:*",
|
||||||
|
"commander": "^13",
|
||||||
|
"picocolors": "^1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@typescript-eslint/parser": "^8",
|
||||||
|
"eslint": "^9",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vitest": "^2"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
packages/cli/src/commands/coord.ts
Normal file
15
packages/cli/src/commands/coord.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
|
||||||
|
import { buildCoordCli } from '@mosaic/coord/dist/cli.js';
|
||||||
|
|
||||||
|
const COMMAND_NAME = 'coord';
|
||||||
|
|
||||||
|
export function registerCoordCommand(program: Command): void {
|
||||||
|
const coordCommand = buildCoordCli().commands.find((command) => command.name() === COMMAND_NAME);
|
||||||
|
|
||||||
|
if (coordCommand === undefined) {
|
||||||
|
throw new Error('Expected @mosaic/coord to expose a "coord" command.');
|
||||||
|
}
|
||||||
|
|
||||||
|
program.addCommand(coordCommand);
|
||||||
|
}
|
||||||
13
packages/cli/src/commands/prdy.ts
Normal file
13
packages/cli/src/commands/prdy.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import pc from 'picocolors';
|
||||||
|
|
||||||
|
// TODO(wave3): Replace this temporary shim once @mosaic/prdy lands in main.
|
||||||
|
export function registerPrdyCommand(program: Command): void {
|
||||||
|
program
|
||||||
|
.command('prdy')
|
||||||
|
.description('PRD workflow commands')
|
||||||
|
.action(() => {
|
||||||
|
console.error(pc.yellow('@mosaic/prdy CLI is not available in this workspace yet.'));
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
15
packages/cli/src/commands/quality-rails.ts
Normal file
15
packages/cli/src/commands/quality-rails.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import pc from 'picocolors';
|
||||||
|
|
||||||
|
// TODO(wave3): Replace this temporary shim once @mosaic/quality-rails lands in main.
|
||||||
|
export function registerQualityRailsCommand(program: Command): void {
|
||||||
|
program
|
||||||
|
.command('quality-rails')
|
||||||
|
.description('Quality rail commands')
|
||||||
|
.action(() => {
|
||||||
|
console.error(
|
||||||
|
pc.yellow('@mosaic/quality-rails CLI is not available in this workspace yet.'),
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
15
packages/cli/src/commands/queue.ts
Normal file
15
packages/cli/src/commands/queue.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
|
||||||
|
import { buildQueueCli } from '@mosaic/queue';
|
||||||
|
|
||||||
|
const COMMAND_NAME = 'queue';
|
||||||
|
|
||||||
|
export function registerQueueCommand(program: Command): void {
|
||||||
|
const queueCommand = buildQueueCli().commands.find((command) => command.name() === COMMAND_NAME);
|
||||||
|
|
||||||
|
if (queueCommand === undefined) {
|
||||||
|
throw new Error('Expected @mosaic/queue to expose a "queue" command.');
|
||||||
|
}
|
||||||
|
|
||||||
|
program.addCommand(queueCommand);
|
||||||
|
}
|
||||||
1
packages/cli/src/index.ts
Normal file
1
packages/cli/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { rootCommand } from './root-command.js';
|
||||||
17
packages/cli/src/root-command.ts
Normal file
17
packages/cli/src/root-command.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
|
||||||
|
import { registerCoordCommand } from './commands/coord.js';
|
||||||
|
import { registerPrdyCommand } from './commands/prdy.js';
|
||||||
|
import { registerQualityRailsCommand } from './commands/quality-rails.js';
|
||||||
|
import { registerQueueCommand } from './commands/queue.js';
|
||||||
|
import { VERSION } from './version.js';
|
||||||
|
|
||||||
|
export const rootCommand = new Command()
|
||||||
|
.name('mosaic')
|
||||||
|
.version(VERSION)
|
||||||
|
.description('Mosaic — AI agent orchestration platform');
|
||||||
|
|
||||||
|
registerCoordCommand(rootCommand);
|
||||||
|
registerPrdyCommand(rootCommand);
|
||||||
|
registerQueueCommand(rootCommand);
|
||||||
|
registerQualityRailsCommand(rootCommand);
|
||||||
1
packages/cli/src/version.ts
Normal file
1
packages/cli/src/version.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const VERSION = '0.1.0';
|
||||||
18
packages/cli/tests/root-command.test.ts
Normal file
18
packages/cli/tests/root-command.test.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { rootCommand } from '../src/root-command.js';
|
||||||
|
|
||||||
|
describe('rootCommand', () => {
|
||||||
|
it('registers all top-level subcommand groups', () => {
|
||||||
|
const registeredSubcommands = rootCommand.commands
|
||||||
|
.map((command) => command.name())
|
||||||
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
|
||||||
|
expect(registeredSubcommands).toEqual([
|
||||||
|
'coord',
|
||||||
|
'prdy',
|
||||||
|
'quality-rails',
|
||||||
|
'queue',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
packages/cli/tsconfig.json
Normal file
5
packages/cli/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "." },
|
||||||
|
"include": ["src", "bin"]
|
||||||
|
}
|
||||||
24
packages/coord/package.json
Normal file
24
packages/coord/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
155
packages/coord/src/cli.ts
Normal file
155
packages/coord/src/cli.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
34
packages/coord/src/index.ts
Normal file
34
packages/coord/src/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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';
|
||||||
415
packages/coord/src/mission.ts
Normal file
415
packages/coord/src/mission.ts
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
488
packages/coord/src/runner.ts
Normal file
488
packages/coord/src/runner.ts
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
183
packages/coord/src/status.ts
Normal file
183
packages/coord/src/status.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
378
packages/coord/src/tasks-file.ts
Normal file
378
packages/coord/src/tasks-file.ts
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
194
packages/coord/src/types.ts
Normal file
194
packages/coord/src/types.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
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'
|
||||||
|
);
|
||||||
|
}
|
||||||
64
packages/coord/tests/mission.test.ts
Normal file
64
packages/coord/tests/mission.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
74
packages/coord/tests/tasks-file.test.ts
Normal file
74
packages/coord/tests/tasks-file.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
5
packages/coord/tsconfig.json
Normal file
5
packages/coord/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
340
packages/mosaic/README.md
Normal file
340
packages/mosaic/README.md
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
39
packages/mosaic/package.json
Normal file
39
packages/mosaic/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
packages/mosaic/src/config/config-service.ts
Normal file
26
packages/mosaic/src/config/config-service.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
163
packages/mosaic/src/config/file-adapter.ts
Normal file
163
packages/mosaic/src/config/file-adapter.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
packages/mosaic/src/config/schemas.ts
Normal file
51
packages/mosaic/src/config/schemas.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
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();
|
||||||
38
packages/mosaic/src/constants.ts
Normal file
38
packages/mosaic/src/constants.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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',
|
||||||
|
]);
|
||||||
20
packages/mosaic/src/errors.ts
Normal file
20
packages/mosaic/src/errors.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
packages/mosaic/src/index.ts
Normal file
81
packages/mosaic/src/index.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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();
|
||||||
44
packages/mosaic/src/platform/detect.ts
Normal file
44
packages/mosaic/src/platform/detect.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
116
packages/mosaic/src/platform/file-ops.ts
Normal file
116
packages/mosaic/src/platform/file-ops.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
157
packages/mosaic/src/prompter/clack-prompter.ts
Normal file
157
packages/mosaic/src/prompter/clack-prompter.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
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('');
|
||||||
|
}
|
||||||
|
}
|
||||||
133
packages/mosaic/src/prompter/headless-prompter.ts
Normal file
133
packages/mosaic/src/prompter/headless-prompter.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
56
packages/mosaic/src/prompter/interface.ts
Normal file
56
packages/mosaic/src/prompter/interface.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
83
packages/mosaic/src/runtime/detector.ts
Normal file
83
packages/mosaic/src/runtime/detector.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
12
packages/mosaic/src/runtime/installer.ts
Normal file
12
packages/mosaic/src/runtime/installer.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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}`;
|
||||||
|
}
|
||||||
112
packages/mosaic/src/runtime/mcp-config.ts
Normal file
112
packages/mosaic/src/runtime/mcp-config.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
99
packages/mosaic/src/skills/catalog.ts
Normal file
99
packages/mosaic/src/skills/catalog.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
packages/mosaic/src/skills/categories.ts
Normal file
86
packages/mosaic/src/skills/categories.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
}
|
||||||
95
packages/mosaic/src/stages/detect-install.ts
Normal file
95
packages/mosaic/src/stages/detect-install.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
177
packages/mosaic/src/stages/finalize.ts
Normal file
177
packages/mosaic/src/stages/finalize.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
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.');
|
||||||
|
}
|
||||||
23
packages/mosaic/src/stages/mode-select.ts
Normal file
23
packages/mosaic/src/stages/mode-select.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
70
packages/mosaic/src/stages/runtime-setup.ts
Normal file
70
packages/mosaic/src/stages/runtime-setup.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
packages/mosaic/src/stages/skills-select.ts
Normal file
84
packages/mosaic/src/stages/skills-select.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
73
packages/mosaic/src/stages/soul-setup.ts
Normal file
73
packages/mosaic/src/stages/soul-setup.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
packages/mosaic/src/stages/tools-setup.ts
Normal file
76
packages/mosaic/src/stages/tools-setup.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
80
packages/mosaic/src/stages/user-setup.ts
Normal file
80
packages/mosaic/src/stages/user-setup.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
18
packages/mosaic/src/stages/welcome.ts
Normal file
18
packages/mosaic/src/stages/welcome.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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?',
|
||||||
|
);
|
||||||
|
}
|
||||||
145
packages/mosaic/src/template/builders.ts
Normal file
145
packages/mosaic/src/template/builders.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
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 };
|
||||||
26
packages/mosaic/src/template/engine.ts
Normal file
26
packages/mosaic/src/template/engine.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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 '';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
53
packages/mosaic/src/types.ts
Normal file
53
packages/mosaic/src/types.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
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[];
|
||||||
|
}
|
||||||
96
packages/mosaic/src/wizard.ts
Normal file
96
packages/mosaic/src/wizard.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
109
packages/mosaic/tests/integration/full-wizard.test.ts
Normal file
109
packages/mosaic/tests/integration/full-wizard.test.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
71
packages/mosaic/tests/stages/detect-install.test.ts
Normal file
71
packages/mosaic/tests/stages/detect-install.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
74
packages/mosaic/tests/stages/soul-setup.test.ts
Normal file
74
packages/mosaic/tests/stages/soul-setup.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
60
packages/mosaic/tests/stages/user-setup.test.ts
Normal file
60
packages/mosaic/tests/stages/user-setup.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
99
packages/mosaic/tests/template/builders.test.ts
Normal file
99
packages/mosaic/tests/template/builders.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
52
packages/mosaic/tests/template/engine.test.ts
Normal file
52
packages/mosaic/tests/template/engine.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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" }');
|
||||||
|
});
|
||||||
|
});
|
||||||
8
packages/mosaic/tsconfig.json
Normal file
8
packages/mosaic/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
"types": "./dist/index.d.ts"
|
"types": "./dist/index.d.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": ["dist"],
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@@ -19,5 +21,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@mosaic/openclaw-context",
|
"name": "@mosaic/openclaw-context",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "OpenClaw \u2192 OpenBrain context engine plugin",
|
"description": "OpenClaw → OpenBrain context engine plugin",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -34,5 +34,9 @@
|
|||||||
"openbrain",
|
"openbrain",
|
||||||
"context-engine",
|
"context-engine",
|
||||||
"plugin"
|
"plugin"
|
||||||
]
|
],
|
||||||
}
|
"publishConfig": {
|
||||||
|
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1340
pnpm-lock.yaml
generated
1340
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user