aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
475 lines • 18.2 kB
JavaScript
/**
* Cross-Framework Operations
*
* Enables cross-framework linking and multi-project orchestration for
* polyglot process management (SDLC + Marketing + Agile coexistence).
*
* Features:
* - Link work items across frameworks
* - Bidirectional metadata updates
* - Work graph visualization (Mermaid)
* - Orphaned link detection
* - Archive and restore work items
* - List all work across frameworks
*
* @module src/plugin/cross-framework-ops
*/
import * as fs from 'fs/promises';
import * as path from 'path';
/**
* Get inverse relationship type
*/
export function getInverseRelationship(relationship) {
const inverses = {
'promotes': 'promoted-by',
'promoted-by': 'promotes',
'implements': 'implemented-by',
'implemented-by': 'implements',
'blocks': 'blocked-by',
'blocked-by': 'blocks',
'depends-on': 'required-by',
'required-by': 'depends-on',
'relates-to': 'relates-to'
};
return inverses[relationship];
}
/**
* CrossFrameworkOps - Manage cross-framework work linking
*
* @example
* ```typescript
* const ops = new CrossFrameworkOps('~/.local/share/ai-writing-guide');
*
* // Link SDLC project to marketing campaign
* await ops.linkWork('sdlc-complete', 'plugin-system', 'marketing-flow', 'plugin-launch', 'promotes');
*
* // List all links
* const links = await ops.listLinks('sdlc-complete', 'plugin-system');
*
* // Visualize work graph
* const mermaid = await ops.visualizeWorkGraph();
* ```
*/
export class CrossFrameworkOps {
aiwgRoot;
registryPath;
constructor(aiwgRoot) {
this.aiwgRoot = aiwgRoot;
this.registryPath = path.join(aiwgRoot, 'registry.json');
}
/**
* Link two work items across frameworks
*
* @param sourceFramework - Source framework ID
* @param sourceId - Source work item ID
* @param targetFramework - Target framework ID
* @param targetId - Target work item ID
* @param relationship - Relationship type
* @returns Link result
*/
async linkWork(sourceFramework, sourceId, targetFramework, targetId, relationship) {
const result = {
success: false,
source: { framework: sourceFramework, id: sourceId },
target: { framework: targetFramework, id: targetId },
relationship
};
try {
// Validate both frameworks exist
const sourceExists = await this.frameworkExists(sourceFramework);
const targetExists = await this.frameworkExists(targetFramework);
if (!sourceExists) {
result.error = `Source framework '${sourceFramework}' is not installed`;
return result;
}
if (!targetExists) {
result.error = `Target framework '${targetFramework}' is not installed`;
return result;
}
// Validate both work items exist
const sourceMetadata = await this.getWorkMetadata(sourceFramework, sourceId);
const targetMetadata = await this.getWorkMetadata(targetFramework, targetId);
if (!sourceMetadata) {
result.error = `Source work item '${sourceId}' not found in framework '${sourceFramework}'`;
return result;
}
if (!targetMetadata) {
result.error = `Target work item '${targetId}' not found in framework '${targetFramework}'`;
return result;
}
// Check if link already exists
const existingLink = sourceMetadata.linkedWork.find(l => l.framework === targetFramework && l.id === targetId);
if (existingLink) {
result.error = `Link already exists between ${sourceFramework}/${sourceId} and ${targetFramework}/${targetId}`;
return result;
}
// Add link to source
sourceMetadata.linkedWork.push({
framework: targetFramework,
id: targetId,
type: targetMetadata.type,
relationship,
linkedDate: new Date().toISOString()
});
sourceMetadata.updatedAt = new Date().toISOString();
// Add inverse link to target
const inverseRelationship = getInverseRelationship(relationship);
targetMetadata.linkedWork.push({
framework: sourceFramework,
id: sourceId,
type: sourceMetadata.type,
relationship: inverseRelationship,
linkedDate: new Date().toISOString()
});
targetMetadata.updatedAt = new Date().toISOString();
// Save both metadata files
await this.saveWorkMetadata(sourceFramework, sourceId, sourceMetadata);
await this.saveWorkMetadata(targetFramework, targetId, targetMetadata);
result.success = true;
}
catch (error) {
result.error = `Failed to link work items: ${error.message}`;
}
return result;
}
/**
* Remove link between two work items
*
* @param sourceFramework - Source framework ID
* @param sourceId - Source work item ID
* @param targetFramework - Target framework ID
* @param targetId - Target work item ID
* @returns Link result
*/
async unlinkWork(sourceFramework, sourceId, targetFramework, targetId) {
const result = {
success: false,
source: { framework: sourceFramework, id: sourceId },
target: { framework: targetFramework, id: targetId },
relationship: 'relates-to'
};
try {
// Get metadata for both work items
const sourceMetadata = await this.getWorkMetadata(sourceFramework, sourceId);
const targetMetadata = await this.getWorkMetadata(targetFramework, targetId);
if (!sourceMetadata) {
result.error = `Source work item '${sourceId}' not found`;
return result;
}
// Remove link from source
const sourceIndex = sourceMetadata.linkedWork.findIndex(l => l.framework === targetFramework && l.id === targetId);
if (sourceIndex >= 0) {
result.relationship = sourceMetadata.linkedWork[sourceIndex].relationship;
sourceMetadata.linkedWork.splice(sourceIndex, 1);
sourceMetadata.updatedAt = new Date().toISOString();
await this.saveWorkMetadata(sourceFramework, sourceId, sourceMetadata);
}
// Remove inverse link from target (if target exists)
if (targetMetadata) {
const targetIndex = targetMetadata.linkedWork.findIndex(l => l.framework === sourceFramework && l.id === sourceId);
if (targetIndex >= 0) {
targetMetadata.linkedWork.splice(targetIndex, 1);
targetMetadata.updatedAt = new Date().toISOString();
await this.saveWorkMetadata(targetFramework, targetId, targetMetadata);
}
}
result.success = true;
}
catch (error) {
result.error = `Failed to unlink work items: ${error.message}`;
}
return result;
}
/**
* List all links for a work item
*
* @param frameworkId - Framework ID
* @param workId - Work item ID
* @returns Array of links
*/
async listLinks(frameworkId, workId) {
const metadata = await this.getWorkMetadata(frameworkId, workId);
return metadata?.linkedWork || [];
}
/**
* List all work items across all frameworks
*
* @param frameworkId - Optional framework filter
* @returns Array of work summaries
*/
async listAllWork(frameworkId) {
const summaries = [];
try {
const registry = await this.loadRegistry();
const frameworks = frameworkId
? registry.plugins.filter(p => p.id === frameworkId && p.type === 'framework')
: registry.plugins.filter(p => p.type === 'framework');
for (const framework of frameworks) {
const projects = await this.getFrameworkProjects(framework.id, framework.path);
for (const project of projects) {
const metadata = await this.getWorkMetadata(framework.id, project);
if (metadata) {
summaries.push({
framework: framework.id,
id: project,
type: metadata.type,
status: metadata.status,
phase: metadata.phase,
linkCount: metadata.linkedWork.length
});
}
}
}
}
catch {
// Registry doesn't exist
}
return summaries;
}
/**
* Visualize work graph in Mermaid format
*
* @param frameworkId - Optional framework filter
* @returns Mermaid diagram string
*/
async visualizeWorkGraph(frameworkId) {
const lines = ['graph TD'];
const allWork = await this.listAllWork(frameworkId);
const processed = new Set();
for (const work of allWork) {
const nodeId = `${work.framework}_${work.id}`.replace(/-/g, '_');
const label = `${work.framework}: ${work.id}<br/>${work.phase || work.status}`;
// Add node
if (!processed.has(nodeId)) {
lines.push(` ${nodeId}["${label}"]`);
processed.add(nodeId);
}
// Add links
const links = await this.listLinks(work.framework, work.id);
for (const link of links) {
const targetNodeId = `${link.framework}_${link.id}`.replace(/-/g, '_');
const edgeKey = `${nodeId}-${targetNodeId}`;
const reverseKey = `${targetNodeId}-${nodeId}`;
// Only add one direction to avoid duplicate edges
if (!processed.has(edgeKey) && !processed.has(reverseKey)) {
lines.push(` ${nodeId} -->|${link.relationship}| ${targetNodeId}`);
processed.add(edgeKey);
}
}
}
return lines.join('\n');
}
/**
* Detect orphaned links (links to non-existent work items)
*
* @returns Array of orphaned links
*/
async detectOrphanedLinks() {
const orphaned = [];
const allWork = await this.listAllWork();
for (const work of allWork) {
const links = await this.listLinks(work.framework, work.id);
for (const link of links) {
const targetExists = await this.workExists(link.framework, link.id);
if (!targetExists) {
orphaned.push({
source: { framework: work.framework, id: work.id },
target: { framework: link.framework, id: link.id },
relationship: link.relationship
});
}
}
}
return orphaned;
}
/**
* Clean up orphaned links
*
* @returns Number of links removed
*/
async cleanOrphanedLinks() {
const orphaned = await this.detectOrphanedLinks();
let cleaned = 0;
for (const link of orphaned) {
const metadata = await this.getWorkMetadata(link.source.framework, link.source.id);
if (metadata) {
const originalLength = metadata.linkedWork.length;
metadata.linkedWork = metadata.linkedWork.filter(l => !(l.framework === link.target.framework && l.id === link.target.id));
if (metadata.linkedWork.length < originalLength) {
metadata.updatedAt = new Date().toISOString();
await this.saveWorkMetadata(link.source.framework, link.source.id, metadata);
cleaned++;
}
}
}
return cleaned;
}
/**
* Archive a work item
*
* @param frameworkId - Framework ID
* @param workId - Work item ID
* @param _reason - Archive reason
* @returns Whether archive succeeded
*/
async archiveWork(frameworkId, workId, _reason) {
const metadata = await this.getWorkMetadata(frameworkId, workId);
if (!metadata) {
return false;
}
metadata.status = 'archived';
metadata.updatedAt = new Date().toISOString();
// Move to archive directory
const framework = await this.getFrameworkInfo(frameworkId);
if (!framework) {
return false;
}
const sourcePath = path.join(this.aiwgRoot, framework.path, 'projects', workId);
const now = new Date();
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const archivePath = path.join(this.aiwgRoot, framework.path, 'archive', yearMonth, workId);
try {
await fs.mkdir(path.dirname(archivePath), { recursive: true });
await this.moveDirectory(sourcePath, archivePath);
// Update metadata in archive location
await fs.writeFile(path.join(archivePath, 'metadata.json'), JSON.stringify(metadata, null, 2));
return true;
}
catch {
return false;
}
}
/**
* Check if a framework exists in the registry
*/
async frameworkExists(frameworkId) {
try {
const registry = await this.loadRegistry();
return registry.plugins.some(p => p.id === frameworkId && p.type === 'framework');
}
catch {
return false;
}
}
/**
* Check if a work item exists
*/
async workExists(frameworkId, workId) {
const metadata = await this.getWorkMetadata(frameworkId, workId);
return metadata !== null;
}
/**
* Get work item metadata
*/
async getWorkMetadata(frameworkId, workId) {
const framework = await this.getFrameworkInfo(frameworkId);
if (!framework) {
return null;
}
const metadataPath = path.join(this.aiwgRoot, framework.path, 'projects', workId, 'metadata.json');
try {
const content = await fs.readFile(metadataPath, 'utf-8');
return JSON.parse(content);
}
catch {
// Create default metadata if project exists but no metadata
const projectPath = path.join(this.aiwgRoot, framework.path, 'projects', workId);
try {
await fs.stat(projectPath);
return {
framework: frameworkId,
id: workId,
type: 'project',
status: 'active',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
linkedWork: []
};
}
catch {
return null;
}
}
}
/**
* Save work item metadata
*/
async saveWorkMetadata(frameworkId, workId, metadata) {
const framework = await this.getFrameworkInfo(frameworkId);
if (!framework) {
throw new Error(`Framework '${frameworkId}' not found`);
}
const projectPath = path.join(this.aiwgRoot, framework.path, 'projects', workId);
await fs.mkdir(projectPath, { recursive: true });
const metadataPath = path.join(projectPath, 'metadata.json');
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
}
/**
* Get framework info from registry
*/
async getFrameworkInfo(frameworkId) {
try {
const registry = await this.loadRegistry();
const framework = registry.plugins.find(p => p.id === frameworkId && p.type === 'framework');
return framework ? { id: framework.id, path: framework.path } : null;
}
catch {
return null;
}
}
/**
* Get all projects in a framework
*/
async getFrameworkProjects(_frameworkId, frameworkPath) {
const projectsDir = path.join(this.aiwgRoot, frameworkPath, 'projects');
try {
const entries = await fs.readdir(projectsDir, { withFileTypes: true });
return entries
.filter(e => e.isDirectory())
.map(e => e.name);
}
catch {
return [];
}
}
/**
* Load registry file
*/
async loadRegistry() {
const content = await fs.readFile(this.registryPath, 'utf-8');
return JSON.parse(content);
}
/**
* Move directory recursively
*/
async moveDirectory(source, dest) {
await this.copyDirectory(source, dest);
await fs.rm(source, { recursive: true, force: true });
}
/**
* Copy directory recursively
*/
async copyDirectory(source, dest) {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(source, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(source, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await this.copyDirectory(srcPath, destPath);
}
else {
await fs.copyFile(srcPath, destPath);
}
}
}
}
/**
* Create CrossFrameworkOps with default AIWG root
*/
export function createCrossFrameworkOps() {
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
const aiwgRoot = path.join(homeDir, '.local', 'share', 'ai-writing-guide');
return new CrossFrameworkOps(aiwgRoot);
}
//# sourceMappingURL=cross-framework-ops.js.map