Merge commit '007f4dfe4b081e81dec4761e0ca39da61f15b554'

This commit is contained in:
Alexa Amundson
2025-11-25 13:50:09 -06:00
9 changed files with 631 additions and 0 deletions

0
agents/.gitkeep Normal file
View File

0
docs/agents/.gitkeep Normal file
View File

View File

@@ -9,6 +9,7 @@
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "ts-node src/index.ts" "dev": "ts-node src/index.ts"
"spawn-agent": "tsx src/agents/spawn-agent.ts" "spawn-agent": "tsx src/agents/spawn-agent.ts"
"spawn-agent": "node scripts/spawn-agent.js"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",

300
scripts/spawn-agent.js Normal file
View File

@@ -0,0 +1,300 @@
#!/usr/bin/env node
/**
* spawn-agent CLI Tool
*
* Creates a new agent with full spec, prompt, workflow, and docs.
*
* Usage: node scripts/spawn-agent.js <agent-id>
* Example: pnpm spawn-agent scribe-support
*/
const fs = require('fs');
const path = require('path');
// Configuration
const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
const WORKFLOWS_DIR = path.join(__dirname, '..', '.github', 'workflows');
const DOCS_DIR = path.join(__dirname, '..', 'docs', 'agents');
/**
* Convert agent ID to human-readable name
* e.g., "scribe-support" -> "Scribe Support"
*/
function idToName(id) {
return id
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Generate role based on agent naming conventions
*/
function inferRole(id) {
const parts = id.toLowerCase().split('-');
// Common role patterns
const roleMap = {
'scribe': 'Documentation and note-taking specialist',
'support': 'User assistance and support handler',
'review': 'Code review and quality assurance agent',
'reviewer': 'Code review and quality assurance agent',
'deploy': 'Deployment and release automation agent',
'monitor': 'System monitoring and alerting agent',
'test': 'Testing and validation agent',
'security': 'Security scanning and vulnerability assessment agent',
'data': 'Data processing and analysis agent',
'notify': 'Notification and communication agent',
'sync': 'Synchronization and integration agent',
'build': 'Build and compilation agent',
'clean': 'Cleanup and maintenance agent'
};
for (const part of parts) {
if (roleMap[part]) {
return roleMap[part];
}
}
return `Specialized agent for ${idToName(id)} tasks`;
}
/**
* Generate traits based on agent ID
*/
function inferTraits(id) {
const baseTraits = ['autonomous', 'reliable'];
const parts = id.toLowerCase().split('-');
const traitMap = {
'scribe': ['detailed', 'organized'],
'support': ['helpful', 'responsive'],
'review': ['thorough', 'analytical'],
'reviewer': ['thorough', 'analytical'],
'deploy': ['cautious', 'systematic'],
'monitor': ['vigilant', 'proactive'],
'test': ['meticulous', 'comprehensive'],
'security': ['vigilant', 'strict'],
'data': ['analytical', 'efficient'],
'notify': ['timely', 'clear'],
'sync': ['coordinated', 'accurate'],
'build': ['efficient', 'robust'],
'clean': ['systematic', 'thorough']
};
for (const part of parts) {
if (traitMap[part]) {
return [...baseTraits, ...traitMap[part]];
}
}
return baseTraits;
}
/**
* Generate tags based on agent ID
*/
function inferTags(id) {
const baseTags = ['agent', 'blackroad-os'];
const parts = id.toLowerCase().split('-');
return [...baseTags, ...parts];
}
/**
* Read template file
*/
function readTemplate(templateName) {
const templatePath = path.join(TEMPLATES_DIR, templateName);
return fs.readFileSync(templatePath, 'utf8');
}
/**
* Replace all placeholders in template
*/
function processTemplate(template, replacements) {
let result = template;
for (const [key, value] of Object.entries(replacements)) {
const regex = new RegExp(`{{${key}}}`, 'g');
result = result.replace(regex, value);
}
return result;
}
/**
* Ensure directory exists
*/
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
/**
* Main spawn agent function
*/
function spawnAgent(agentId, options = {}) {
const {
skipDocs = false,
verbose = false
} = options;
// Validate agent ID
if (!agentId || typeof agentId !== 'string') {
throw new Error('Agent ID is required');
}
// Normalize agent ID
const normalizedId = agentId.toLowerCase().replace(/[^a-z0-9-]/g, '-');
// Generate agent metadata
const agentName = idToName(normalizedId);
const agentRole = inferRole(normalizedId);
const agentTraits = inferTraits(normalizedId);
const agentTags = inferTags(normalizedId);
const createdAt = new Date().toISOString();
const description = `${agentName} agent for the BlackRoad-OS ecosystem`;
// Prepare replacements
const replacements = {
'AGENT_ID': normalizedId,
'AGENT_NAME': agentName,
'AGENT_ROLE': agentRole,
'AGENT_DESCRIPTION': description,
'AGENT_TRAITS': JSON.stringify(agentTraits),
'AGENT_TRAITS_LIST': agentTraits.map(t => `- ${t}`).join('\n'),
'AGENT_TRAITS_MDX': agentTraits.map(t => `- **${t}**`).join('\n'),
'AGENT_TAGS': JSON.stringify(agentTags),
'CREATED_AT': createdAt
};
// Ensure output directories exist
ensureDir(AGENTS_DIR);
ensureDir(WORKFLOWS_DIR);
if (!skipDocs) {
ensureDir(DOCS_DIR);
}
// Check if agent already exists
const agentJsonPath = path.join(AGENTS_DIR, `${normalizedId}.agent.json`);
if (fs.existsSync(agentJsonPath)) {
throw new Error(`Agent '${normalizedId}' already exists at ${agentJsonPath}`);
}
// Process and write templates
const outputs = [];
// 1. Agent JSON spec
const agentJson = processTemplate(readTemplate('base-agent.template.json'), replacements);
fs.writeFileSync(agentJsonPath, agentJson);
outputs.push(`agents/${normalizedId}.agent.json`);
// 2. Agent prompt
const promptPath = path.join(AGENTS_DIR, `${normalizedId}.prompt.txt`);
const agentPrompt = processTemplate(readTemplate('base-agent.prompt.template.txt'), replacements);
fs.writeFileSync(promptPath, agentPrompt);
outputs.push(`agents/${normalizedId}.prompt.txt`);
// 3. Workflow YAML
const workflowPath = path.join(WORKFLOWS_DIR, `${normalizedId}.workflow.yml`);
const agentWorkflow = processTemplate(readTemplate('base-agent.workflow.template.yml'), replacements);
fs.writeFileSync(workflowPath, agentWorkflow);
outputs.push(`.github/workflows/${normalizedId}.workflow.yml`);
// 4. MDX docs (optional)
if (!skipDocs) {
const mdxPath = path.join(DOCS_DIR, `${normalizedId}.mdx`);
const agentMdx = processTemplate(readTemplate('base-agent.mdx.template'), replacements);
fs.writeFileSync(mdxPath, agentMdx);
outputs.push(`docs/agents/${normalizedId}.mdx`);
}
return {
agentId: normalizedId,
agentName,
outputs,
metadata: {
role: agentRole,
traits: agentTraits,
tags: agentTags,
createdAt
}
};
}
/**
* CLI entry point
*/
function main() {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
console.log(`
🛠️ spawn-agent BlackRoad-OS Agent Generator
Usage:
node scripts/spawn-agent.js <agent-id> [options]
pnpm spawn-agent <agent-id> [options]
Arguments:
agent-id Unique identifier for the agent (e.g., scribe-support)
Options:
--skip-docs Skip generating MDX documentation
--verbose Show detailed output
--help, -h Show this help message
Examples:
pnpm spawn-agent scribe-support
pnpm spawn-agent code-reviewer --skip-docs
pnpm spawn-agent deploy-bot --verbose
`);
process.exit(0);
}
const agentId = args[0];
const skipDocs = args.includes('--skip-docs');
const verbose = args.includes('--verbose');
try {
console.log(`\n🛠️ Spawning agent: ${agentId}\n`);
const result = spawnAgent(agentId, { skipDocs, verbose });
console.log(`✔ Created agent: ${result.agentName}`);
result.outputs.forEach((output, index) => {
const prefix = index === result.outputs.length - 1 ? '└─' : '├─';
console.log(`${prefix} ${output}`);
});
if (verbose) {
console.log('\n📋 Metadata:');
console.log(` Role: ${result.metadata.role}`);
console.log(` Traits: ${result.metadata.traits.join(', ')}`);
console.log(` Tags: ${result.metadata.tags.join(', ')}`);
console.log(` Created: ${result.metadata.createdAt}`);
}
console.log('\n💚 Agent spawned successfully!\n');
} catch (error) {
console.error(`\n❌ Error: ${error.message}\n`);
process.exit(1);
}
}
// Export for testing
module.exports = {
spawnAgent,
idToName,
inferRole,
inferTraits,
inferTags,
processTemplate
};
// Run CLI if executed directly
if (require.main === module) {
main();
}

View File

@@ -0,0 +1,52 @@
---
title: {{AGENT_NAME}}
description: {{AGENT_DESCRIPTION}}
---
# {{AGENT_NAME}}
> Agent ID: `{{AGENT_ID}}`
## Overview
{{AGENT_DESCRIPTION}}
## Role
{{AGENT_ROLE}}
## Traits
{{AGENT_TRAITS_MDX}}
## Configuration
```json
{
"id": "{{AGENT_ID}}",
"version": "1.0.0",
"triggers": ["issue_comment.created", "workflow_dispatch"]
}
```
## Usage
This agent is triggered automatically by configured events. You can also trigger it manually via workflow dispatch.
### Manual Trigger
1. Go to Actions tab in the repository
2. Select "{{AGENT_NAME}} Agent Handler"
3. Click "Run workflow"
## Files
| File | Description |
|------|-------------|
| `agents/{{AGENT_ID}}.agent.json` | Agent specification |
| `agents/{{AGENT_ID}}.prompt.txt` | Agent personality and instructions |
| `.github/workflows/{{AGENT_ID}}.workflow.yml` | GitHub Actions workflow |
## Created
{{CREATED_AT}}

View File

@@ -0,0 +1,31 @@
# {{AGENT_NAME}} Agent Prompt
## Identity
You are **{{AGENT_NAME}}**, an autonomous agent in the BlackRoad-OS ecosystem.
## Role
{{AGENT_ROLE}}
## Description
{{AGENT_DESCRIPTION}}
## Traits
{{AGENT_TRAITS_LIST}}
## Instructions
1. Monitor incoming events based on your configured triggers
2. Process relevant data according to your specialized role
3. Generate appropriate outputs and artifacts
4. Log all significant actions for auditability
## Response Format
When responding, use clear, structured output:
- Status: [success|warning|error]
- Action: Brief description of what you did
- Output: Relevant data or results
- Next Steps: Recommendations for follow-up (if any)
## Boundaries
- Stay within your defined role and capabilities
- Escalate complex issues to human operators when necessary
- Maintain security and privacy standards at all times

View File

@@ -0,0 +1,26 @@
{
"id": "{{AGENT_ID}}",
"name": "{{AGENT_NAME}}",
"version": "1.0.0",
"role": "{{AGENT_ROLE}}",
"description": "{{AGENT_DESCRIPTION}}",
"traits": {{AGENT_TRAITS}},
"triggers": {
"events": ["issue_comment.created", "workflow_dispatch"],
"schedule": null
},
"outputs": {
"logs": true,
"artifacts": [],
"notifications": []
},
"dsl": {
"entrypoint": "agents/{{AGENT_ID}}.prompt.txt",
"workflow": ".github/workflows/{{AGENT_ID}}.workflow.yml"
},
"metadata": {
"author": "BlackRoad-OS",
"createdAt": "{{CREATED_AT}}",
"tags": {{AGENT_TAGS}}
}
}

View File

@@ -0,0 +1,30 @@
name: 🤖 {{AGENT_NAME}} Agent Handler
on:
issue_comment:
types: [created]
workflow_dispatch:
jobs:
{{AGENT_ID}}:
runs-on: ubuntu-latest
steps:
- name: 🧬 Checkout Repo
uses: actions/checkout@v3
- name: 🧠 Set up Node
uses: actions/setup-node@v3
with:
node-version: 18
- name: 📦 Install Dependencies
run: npm ci
- name: 🚦 Run {{AGENT_NAME}} Handler
run: |
echo "Running {{AGENT_NAME}} agent..."
echo "Agent ID: {{AGENT_ID}}"
echo "Trigger: ${{ github.event_name }}"
env:
AGENT_ID: {{AGENT_ID}}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

191
tests/spawn-agent.test.ts Normal file
View File

@@ -0,0 +1,191 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import path from "path";
const {
spawnAgent,
idToName,
inferRole,
inferTraits,
inferTags,
processTemplate
} = require("../scripts/spawn-agent.js");
// Test output directories
const TEST_AGENTS_DIR = path.join(__dirname, "..", "agents");
const TEST_WORKFLOWS_DIR = path.join(__dirname, "..", ".github", "workflows");
const TEST_DOCS_DIR = path.join(__dirname, "..", "docs", "agents");
describe("spawn-agent utilities", () => {
describe("idToName", () => {
it("converts hyphenated ID to title case", () => {
expect(idToName("scribe-support")).toBe("Scribe Support");
expect(idToName("code-reviewer")).toBe("Code Reviewer");
expect(idToName("deploy-bot")).toBe("Deploy Bot");
});
it("handles single word IDs", () => {
expect(idToName("monitor")).toBe("Monitor");
});
it("handles multiple hyphens", () => {
expect(idToName("super-code-review-bot")).toBe("Super Code Review Bot");
});
});
describe("inferRole", () => {
it("infers role from known keywords", () => {
expect(inferRole("scribe-support")).toContain("Documentation");
expect(inferRole("code-reviewer")).toContain("Code review");
expect(inferRole("deploy-bot")).toContain("Deployment");
expect(inferRole("security-scanner")).toContain("Security");
});
it("provides default role for unknown keywords", () => {
const role = inferRole("custom-agent");
expect(role).toContain("Custom Agent");
});
});
describe("inferTraits", () => {
it("includes base traits for all agents", () => {
const traits = inferTraits("any-agent");
expect(traits).toContain("autonomous");
expect(traits).toContain("reliable");
});
it("adds specific traits based on keywords", () => {
const scribeTraits = inferTraits("scribe-support");
expect(scribeTraits).toContain("detailed");
expect(scribeTraits).toContain("organized");
const reviewTraits = inferTraits("code-reviewer");
expect(reviewTraits).toContain("thorough");
expect(reviewTraits).toContain("analytical");
});
});
describe("inferTags", () => {
it("includes base tags", () => {
const tags = inferTags("any-agent");
expect(tags).toContain("agent");
expect(tags).toContain("blackroad-os");
});
it("includes agent ID parts as tags", () => {
const tags = inferTags("scribe-support");
expect(tags).toContain("scribe");
expect(tags).toContain("support");
});
});
describe("processTemplate", () => {
it("replaces placeholders with values", () => {
const template = "Hello {{NAME}}, your role is {{ROLE}}.";
const result = processTemplate(template, {
NAME: "TestAgent",
ROLE: "Tester"
});
expect(result).toBe("Hello TestAgent, your role is Tester.");
});
it("replaces multiple occurrences", () => {
const template = "{{ID}} is {{ID}}";
const result = processTemplate(template, { ID: "test" });
expect(result).toBe("test is test");
});
});
});
describe("spawnAgent", () => {
const testAgentId = "test-spawn-agent-" + Date.now();
let createdFiles: string[] = [];
afterEach(() => {
// Clean up created files
for (const file of createdFiles) {
try {
if (fs.existsSync(file)) {
fs.unlinkSync(file);
}
} catch {
// Ignore cleanup errors
}
}
createdFiles = [];
});
it("creates agent files successfully", () => {
const result = spawnAgent(testAgentId);
expect(result.agentId).toBe(testAgentId);
expect(result.outputs).toHaveLength(4);
// Track created files for cleanup
createdFiles = [
path.join(TEST_AGENTS_DIR, `${testAgentId}.agent.json`),
path.join(TEST_AGENTS_DIR, `${testAgentId}.prompt.txt`),
path.join(TEST_WORKFLOWS_DIR, `${testAgentId}.workflow.yml`),
path.join(TEST_DOCS_DIR, `${testAgentId}.mdx`)
];
// Verify files were created
expect(fs.existsSync(createdFiles[0])).toBe(true);
expect(fs.existsSync(createdFiles[1])).toBe(true);
expect(fs.existsSync(createdFiles[2])).toBe(true);
expect(fs.existsSync(createdFiles[3])).toBe(true);
// Verify JSON content
const agentJson = JSON.parse(fs.readFileSync(createdFiles[0], "utf8"));
expect(agentJson.id).toBe(testAgentId);
expect(agentJson.version).toBe("1.0.0");
expect(agentJson.metadata.author).toBe("BlackRoad-OS");
});
it("skips docs when option is set", () => {
const result = spawnAgent(testAgentId + "-nodocs", { skipDocs: true });
expect(result.outputs).toHaveLength(3);
expect(result.outputs.some((o: string) => o.includes(".mdx"))).toBe(false);
// Track created files for cleanup
createdFiles = [
path.join(TEST_AGENTS_DIR, `${testAgentId}-nodocs.agent.json`),
path.join(TEST_AGENTS_DIR, `${testAgentId}-nodocs.prompt.txt`),
path.join(TEST_WORKFLOWS_DIR, `${testAgentId}-nodocs.workflow.yml`)
];
});
it("throws error if agent already exists", () => {
// Create first agent
spawnAgent(testAgentId + "-dup");
// Track for cleanup
createdFiles = [
path.join(TEST_AGENTS_DIR, `${testAgentId}-dup.agent.json`),
path.join(TEST_AGENTS_DIR, `${testAgentId}-dup.prompt.txt`),
path.join(TEST_WORKFLOWS_DIR, `${testAgentId}-dup.workflow.yml`),
path.join(TEST_DOCS_DIR, `${testAgentId}-dup.mdx`)
];
// Try to create duplicate
expect(() => spawnAgent(testAgentId + "-dup")).toThrow("already exists");
});
it("throws error if agent ID is missing", () => {
expect(() => spawnAgent("")).toThrow("Agent ID is required");
expect(() => spawnAgent(null as any)).toThrow("Agent ID is required");
});
it("normalizes agent ID", () => {
const result = spawnAgent("Test_Agent.123");
expect(result.agentId).toBe("test-agent-123");
createdFiles = [
path.join(TEST_AGENTS_DIR, "test-agent-123.agent.json"),
path.join(TEST_AGENTS_DIR, "test-agent-123.prompt.txt"),
path.join(TEST_WORKFLOWS_DIR, "test-agent-123.workflow.yml"),
path.join(TEST_DOCS_DIR, "test-agent-123.mdx")
];
});
});