mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
feat: add orchestration workflows and harness skills
This commit is contained in:
@@ -16,15 +16,17 @@ const {
|
||||
getDateString,
|
||||
getTimeString,
|
||||
getSessionIdShort,
|
||||
getProjectName,
|
||||
ensureDir,
|
||||
readFile,
|
||||
writeFile,
|
||||
replaceInFile,
|
||||
runCommand,
|
||||
log
|
||||
} = require('../lib/utils');
|
||||
|
||||
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
|
||||
const SUMMARY_END_MARKER = '<!-- ECC:SUMMARY:END -->';
|
||||
const SESSION_SEPARATOR = '\n---\n';
|
||||
|
||||
/**
|
||||
* Extract a meaningful summary from the session transcript.
|
||||
@@ -128,6 +130,51 @@ function runMain() {
|
||||
});
|
||||
}
|
||||
|
||||
function getSessionMetadata() {
|
||||
const branchResult = runCommand('git rev-parse --abbrev-ref HEAD');
|
||||
|
||||
return {
|
||||
project: getProjectName() || 'unknown',
|
||||
branch: branchResult.success ? branchResult.output : 'unknown',
|
||||
worktree: process.cwd()
|
||||
};
|
||||
}
|
||||
|
||||
function extractHeaderField(header, label) {
|
||||
const match = header.match(new RegExp(`\\*\\*${escapeRegExp(label)}:\\*\\*\\s*(.+)$`, 'm'));
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
function buildSessionHeader(today, currentTime, metadata, existingContent = '') {
|
||||
const headingMatch = existingContent.match(/^#\s+.+$/m);
|
||||
const heading = headingMatch ? headingMatch[0] : `# Session: ${today}`;
|
||||
const date = extractHeaderField(existingContent, 'Date') || today;
|
||||
const started = extractHeaderField(existingContent, 'Started') || currentTime;
|
||||
|
||||
return [
|
||||
heading,
|
||||
`**Date:** ${date}`,
|
||||
`**Started:** ${started}`,
|
||||
`**Last Updated:** ${currentTime}`,
|
||||
`**Project:** ${metadata.project}`,
|
||||
`**Branch:** ${metadata.branch}`,
|
||||
`**Worktree:** ${metadata.worktree}`,
|
||||
''
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function mergeSessionHeader(content, today, currentTime, metadata) {
|
||||
const separatorIndex = content.indexOf(SESSION_SEPARATOR);
|
||||
if (separatorIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingHeader = content.slice(0, separatorIndex);
|
||||
const body = content.slice(separatorIndex + SESSION_SEPARATOR.length);
|
||||
const nextHeader = buildSessionHeader(today, currentTime, metadata, existingHeader);
|
||||
return `${nextHeader}${SESSION_SEPARATOR}${body}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Parse stdin JSON to get transcript_path
|
||||
let transcriptPath = null;
|
||||
@@ -143,6 +190,7 @@ async function main() {
|
||||
const today = getDateString();
|
||||
const shortId = getSessionIdShort();
|
||||
const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`);
|
||||
const sessionMetadata = getSessionMetadata();
|
||||
|
||||
ensureDir(sessionsDir);
|
||||
|
||||
@@ -160,42 +208,42 @@ async function main() {
|
||||
}
|
||||
|
||||
if (fs.existsSync(sessionFile)) {
|
||||
// Update existing session file
|
||||
const updated = replaceInFile(
|
||||
sessionFile,
|
||||
/\*\*Last Updated:\*\*.*/,
|
||||
`**Last Updated:** ${currentTime}`
|
||||
);
|
||||
if (!updated) {
|
||||
log(`[SessionEnd] Failed to update timestamp in ${sessionFile}`);
|
||||
const existing = readFile(sessionFile);
|
||||
let updatedContent = existing;
|
||||
|
||||
if (existing) {
|
||||
const merged = mergeSessionHeader(existing, today, currentTime, sessionMetadata);
|
||||
if (merged) {
|
||||
updatedContent = merged;
|
||||
} else {
|
||||
log(`[SessionEnd] Failed to normalize header in ${sessionFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a new summary, update only the generated summary block.
|
||||
// This keeps repeated Stop invocations idempotent and preserves
|
||||
// user-authored sections in the same session file.
|
||||
if (summary) {
|
||||
const existing = readFile(sessionFile);
|
||||
if (existing) {
|
||||
const summaryBlock = buildSummaryBlock(summary);
|
||||
let updatedContent = existing;
|
||||
if (summary && updatedContent) {
|
||||
const summaryBlock = buildSummaryBlock(summary);
|
||||
|
||||
if (existing.includes(SUMMARY_START_MARKER) && existing.includes(SUMMARY_END_MARKER)) {
|
||||
updatedContent = existing.replace(
|
||||
new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`),
|
||||
summaryBlock
|
||||
);
|
||||
} else {
|
||||
// Migration path for files created before summary markers existed.
|
||||
updatedContent = existing.replace(
|
||||
/## (?:Session Summary|Current State)[\s\S]*?$/,
|
||||
`${summaryBlock}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`\n`
|
||||
);
|
||||
}
|
||||
|
||||
writeFile(sessionFile, updatedContent);
|
||||
if (updatedContent.includes(SUMMARY_START_MARKER) && updatedContent.includes(SUMMARY_END_MARKER)) {
|
||||
updatedContent = updatedContent.replace(
|
||||
new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`),
|
||||
summaryBlock
|
||||
);
|
||||
} else {
|
||||
// Migration path for files created before summary markers existed.
|
||||
updatedContent = updatedContent.replace(
|
||||
/## (?:Session Summary|Current State)[\s\S]*?$/,
|
||||
`${summaryBlock}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedContent) {
|
||||
writeFile(sessionFile, updatedContent);
|
||||
}
|
||||
|
||||
log(`[SessionEnd] Updated session file: ${sessionFile}`);
|
||||
} else {
|
||||
// Create new session file
|
||||
@@ -203,14 +251,7 @@ async function main() {
|
||||
? `${buildSummaryBlock(summary)}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``
|
||||
: `## Current State\n\n[Session context goes here]\n\n### Completed\n- [ ]\n\n### In Progress\n- [ ]\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``;
|
||||
|
||||
const template = `# Session: ${today}
|
||||
**Date:** ${today}
|
||||
**Started:** ${currentTime}
|
||||
**Last Updated:** ${currentTime}
|
||||
|
||||
---
|
||||
|
||||
${summarySection}
|
||||
const template = `${buildSessionHeader(today, currentTime, sessionMetadata)}${SESSION_SEPARATOR}${summarySection}
|
||||
`;
|
||||
|
||||
writeFile(sessionFile, template);
|
||||
|
||||
295
scripts/lib/orchestration-session.js
Normal file
295
scripts/lib/orchestration-session.js
Normal file
@@ -0,0 +1,295 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
function stripCodeTicks(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('`') && trimmed.endsWith('`') && trimmed.length >= 2) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseSection(content, heading) {
|
||||
if (typeof content !== 'string' || content.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
const headingLines = new Set([`## ${heading}`, `**${heading}**`]);
|
||||
const startIndex = lines.findIndex(line => headingLines.has(line.trim()));
|
||||
|
||||
if (startIndex === -1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const collected = [];
|
||||
for (let index = startIndex + 1; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('## ') || (/^\*\*.+\*\*$/.test(trimmed) && !headingLines.has(trimmed))) {
|
||||
break;
|
||||
}
|
||||
collected.push(line);
|
||||
}
|
||||
|
||||
return collected.join('\n').trim();
|
||||
}
|
||||
|
||||
function parseBullets(section) {
|
||||
if (!section) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return section
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.startsWith('- '))
|
||||
.map(line => stripCodeTicks(line.replace(/^- /, '').trim()));
|
||||
}
|
||||
|
||||
function parseWorkerStatus(content) {
|
||||
const status = {
|
||||
state: null,
|
||||
updated: null,
|
||||
branch: null,
|
||||
worktree: null,
|
||||
taskFile: null,
|
||||
handoffFile: null
|
||||
};
|
||||
|
||||
if (typeof content !== 'string' || content.length === 0) {
|
||||
return status;
|
||||
}
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const match = line.match(/^- ([A-Za-z ]+):\s*(.+)$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = match[1].trim().toLowerCase().replace(/\s+/g, '');
|
||||
const value = stripCodeTicks(match[2]);
|
||||
|
||||
if (key === 'state') status.state = value;
|
||||
if (key === 'updated') status.updated = value;
|
||||
if (key === 'branch') status.branch = value;
|
||||
if (key === 'worktree') status.worktree = value;
|
||||
if (key === 'taskfile') status.taskFile = value;
|
||||
if (key === 'handofffile') status.handoffFile = value;
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
function parseWorkerTask(content) {
|
||||
return {
|
||||
objective: parseSection(content, 'Objective'),
|
||||
seedPaths: parseBullets(parseSection(content, 'Seeded Local Overlays'))
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorkerHandoff(content) {
|
||||
return {
|
||||
summary: parseBullets(parseSection(content, 'Summary')),
|
||||
validation: parseBullets(parseSection(content, 'Validation')),
|
||||
remainingRisks: parseBullets(parseSection(content, 'Remaining Risks'))
|
||||
};
|
||||
}
|
||||
|
||||
function readTextIfExists(filePath) {
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
function listWorkerDirectories(coordinationDir) {
|
||||
if (!coordinationDir || !fs.existsSync(coordinationDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(coordinationDir, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.filter(entry => {
|
||||
const workerDir = path.join(coordinationDir, entry.name);
|
||||
return ['status.md', 'task.md', 'handoff.md']
|
||||
.some(filename => fs.existsSync(path.join(workerDir, filename)));
|
||||
})
|
||||
.map(entry => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function loadWorkerSnapshots(coordinationDir) {
|
||||
return listWorkerDirectories(coordinationDir).map(workerSlug => {
|
||||
const workerDir = path.join(coordinationDir, workerSlug);
|
||||
const statusPath = path.join(workerDir, 'status.md');
|
||||
const taskPath = path.join(workerDir, 'task.md');
|
||||
const handoffPath = path.join(workerDir, 'handoff.md');
|
||||
|
||||
const status = parseWorkerStatus(readTextIfExists(statusPath));
|
||||
const task = parseWorkerTask(readTextIfExists(taskPath));
|
||||
const handoff = parseWorkerHandoff(readTextIfExists(handoffPath));
|
||||
|
||||
return {
|
||||
workerSlug,
|
||||
workerDir,
|
||||
status,
|
||||
task,
|
||||
handoff,
|
||||
files: {
|
||||
status: statusPath,
|
||||
task: taskPath,
|
||||
handoff: handoffPath
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function listTmuxPanes(sessionName) {
|
||||
const format = [
|
||||
'#{pane_id}',
|
||||
'#{window_index}',
|
||||
'#{pane_index}',
|
||||
'#{pane_title}',
|
||||
'#{pane_current_command}',
|
||||
'#{pane_current_path}',
|
||||
'#{pane_active}',
|
||||
'#{pane_dead}',
|
||||
'#{pane_pid}'
|
||||
].join('\t');
|
||||
|
||||
const result = spawnSync('tmux', ['list-panes', '-t', sessionName, '-F', format], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (result.stdout || '')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
const [
|
||||
paneId,
|
||||
windowIndex,
|
||||
paneIndex,
|
||||
title,
|
||||
currentCommand,
|
||||
currentPath,
|
||||
active,
|
||||
dead,
|
||||
pid
|
||||
] = line.split('\t');
|
||||
|
||||
return {
|
||||
paneId,
|
||||
windowIndex: Number(windowIndex),
|
||||
paneIndex: Number(paneIndex),
|
||||
title,
|
||||
currentCommand,
|
||||
currentPath,
|
||||
active: active === '1',
|
||||
dead: dead === '1',
|
||||
pid: pid ? Number(pid) : null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeWorkerStates(workers) {
|
||||
return workers.reduce((counts, worker) => {
|
||||
const state = worker.status.state || 'unknown';
|
||||
counts[state] = (counts[state] || 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function buildSessionSnapshot({ sessionName, coordinationDir, panes }) {
|
||||
const workerSnapshots = loadWorkerSnapshots(coordinationDir);
|
||||
const paneMap = new Map(panes.map(pane => [pane.title, pane]));
|
||||
|
||||
const workers = workerSnapshots.map(worker => ({
|
||||
...worker,
|
||||
pane: paneMap.get(worker.workerSlug) || null
|
||||
}));
|
||||
|
||||
return {
|
||||
sessionName,
|
||||
coordinationDir,
|
||||
sessionActive: panes.length > 0,
|
||||
paneCount: panes.length,
|
||||
workerCount: workers.length,
|
||||
workerStates: summarizeWorkerStates(workers),
|
||||
panes,
|
||||
workers
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSnapshotTarget(targetPath, cwd = process.cwd()) {
|
||||
const absoluteTarget = path.resolve(cwd, targetPath);
|
||||
|
||||
if (fs.existsSync(absoluteTarget) && fs.statSync(absoluteTarget).isFile()) {
|
||||
const config = JSON.parse(fs.readFileSync(absoluteTarget, 'utf8'));
|
||||
const repoRoot = path.resolve(config.repoRoot || cwd);
|
||||
const coordinationRoot = path.resolve(
|
||||
config.coordinationRoot || path.join(repoRoot, '.orchestration')
|
||||
);
|
||||
|
||||
return {
|
||||
sessionName: config.sessionName,
|
||||
coordinationDir: path.join(coordinationRoot, config.sessionName),
|
||||
repoRoot,
|
||||
targetType: 'plan'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sessionName: targetPath,
|
||||
coordinationDir: path.join(cwd, '.claude', 'orchestration', targetPath),
|
||||
repoRoot: cwd,
|
||||
targetType: 'session'
|
||||
};
|
||||
}
|
||||
|
||||
function collectSessionSnapshot(targetPath, cwd = process.cwd()) {
|
||||
const target = resolveSnapshotTarget(targetPath, cwd);
|
||||
const panes = listTmuxPanes(target.sessionName);
|
||||
const snapshot = buildSessionSnapshot({
|
||||
sessionName: target.sessionName,
|
||||
coordinationDir: target.coordinationDir,
|
||||
panes
|
||||
});
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
repoRoot: target.repoRoot,
|
||||
targetType: target.targetType
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildSessionSnapshot,
|
||||
collectSessionSnapshot,
|
||||
listTmuxPanes,
|
||||
loadWorkerSnapshots,
|
||||
normalizeText: stripCodeTicks,
|
||||
parseWorkerHandoff,
|
||||
parseWorkerStatus,
|
||||
parseWorkerTask,
|
||||
resolveSnapshotTarget
|
||||
};
|
||||
@@ -85,6 +85,9 @@ function parseSessionMetadata(content) {
|
||||
date: null,
|
||||
started: null,
|
||||
lastUpdated: null,
|
||||
project: null,
|
||||
branch: null,
|
||||
worktree: null,
|
||||
completed: [],
|
||||
inProgress: [],
|
||||
notes: '',
|
||||
@@ -117,6 +120,22 @@ function parseSessionMetadata(content) {
|
||||
metadata.lastUpdated = updatedMatch[1];
|
||||
}
|
||||
|
||||
// Extract control-plane metadata
|
||||
const projectMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m);
|
||||
if (projectMatch) {
|
||||
metadata.project = projectMatch[1].trim();
|
||||
}
|
||||
|
||||
const branchMatch = content.match(/\*\*Branch:\*\*\s*(.+)$/m);
|
||||
if (branchMatch) {
|
||||
metadata.branch = branchMatch[1].trim();
|
||||
}
|
||||
|
||||
const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.+)$/m);
|
||||
if (worktreeMatch) {
|
||||
metadata.worktree = worktreeMatch[1].trim();
|
||||
}
|
||||
|
||||
// Extract completed items
|
||||
const completedSection = content.match(/### Completed\s*\n([\s\S]*?)(?=###|\n\n|$)/);
|
||||
if (completedSection) {
|
||||
|
||||
491
scripts/lib/tmux-worktree-orchestrator.js
Normal file
491
scripts/lib/tmux-worktree-orchestrator.js
Normal file
@@ -0,0 +1,491 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
function slugify(value, fallback = 'worker') {
|
||||
const normalized = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function renderTemplate(template, variables) {
|
||||
if (typeof template !== 'string' || template.trim().length === 0) {
|
||||
throw new Error('launcherCommand must be a non-empty string');
|
||||
}
|
||||
|
||||
return template.replace(/\{([a-z_]+)\}/g, (match, key) => {
|
||||
if (!(key in variables)) {
|
||||
throw new Error(`Unknown template variable: ${key}`);
|
||||
}
|
||||
return String(variables[key]);
|
||||
});
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function formatCommand(program, args) {
|
||||
return [program, ...args.map(shellQuote)].join(' ');
|
||||
}
|
||||
|
||||
function normalizeSeedPaths(seedPaths, repoRoot) {
|
||||
const resolvedRepoRoot = path.resolve(repoRoot);
|
||||
const entries = Array.isArray(seedPaths) ? seedPaths : [];
|
||||
const seen = new Set();
|
||||
const normalized = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (typeof entry !== 'string' || entry.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(resolvedRepoRoot, entry);
|
||||
const relativePath = path.relative(resolvedRepoRoot, absolutePath);
|
||||
|
||||
if (
|
||||
relativePath.startsWith('..') ||
|
||||
path.isAbsolute(relativePath)
|
||||
) {
|
||||
throw new Error(`seedPaths entries must stay inside repoRoot: ${entry}`);
|
||||
}
|
||||
|
||||
const normalizedPath = relativePath.split(path.sep).join('/');
|
||||
if (seen.has(normalizedPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(normalizedPath);
|
||||
normalized.push(normalizedPath);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function overlaySeedPaths({ repoRoot, seedPaths, worktreePath }) {
|
||||
const normalizedSeedPaths = normalizeSeedPaths(seedPaths, repoRoot);
|
||||
|
||||
for (const seedPath of normalizedSeedPaths) {
|
||||
const sourcePath = path.join(repoRoot, seedPath);
|
||||
const destinationPath = path.join(worktreePath, seedPath);
|
||||
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
throw new Error(`Seed path does not exist in repoRoot: ${seedPath}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||
fs.rmSync(destinationPath, { force: true, recursive: true });
|
||||
fs.cpSync(sourcePath, destinationPath, {
|
||||
dereference: false,
|
||||
force: true,
|
||||
preserveTimestamps: true,
|
||||
recursive: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkerArtifacts(workerPlan) {
|
||||
const seededPathsSection = workerPlan.seedPaths.length > 0
|
||||
? [
|
||||
'',
|
||||
'## Seeded Local Overlays',
|
||||
...workerPlan.seedPaths.map(seedPath => `- \`${seedPath}\``)
|
||||
]
|
||||
: [];
|
||||
|
||||
return {
|
||||
dir: workerPlan.coordinationDir,
|
||||
files: [
|
||||
{
|
||||
path: workerPlan.taskFilePath,
|
||||
content: [
|
||||
`# Worker Task: ${workerPlan.workerName}`,
|
||||
'',
|
||||
`- Session: \`${workerPlan.sessionName}\``,
|
||||
`- Repo root: \`${workerPlan.repoRoot}\``,
|
||||
`- Worktree: \`${workerPlan.worktreePath}\``,
|
||||
`- Branch: \`${workerPlan.branchName}\``,
|
||||
`- Launcher status file: \`${workerPlan.statusFilePath}\``,
|
||||
`- Launcher handoff file: \`${workerPlan.handoffFilePath}\``,
|
||||
...seededPathsSection,
|
||||
'',
|
||||
'## Objective',
|
||||
workerPlan.task,
|
||||
'',
|
||||
'## Completion',
|
||||
'Do not spawn subagents or external agents for this task.',
|
||||
'Report results in your final response.',
|
||||
`The worker launcher captures your response in \`${workerPlan.handoffFilePath}\` automatically.`,
|
||||
`The worker launcher updates \`${workerPlan.statusFilePath}\` automatically.`
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
path: workerPlan.handoffFilePath,
|
||||
content: [
|
||||
`# Handoff: ${workerPlan.workerName}`,
|
||||
'',
|
||||
'## Summary',
|
||||
'- Pending',
|
||||
'',
|
||||
'## Files Changed',
|
||||
'- Pending',
|
||||
'',
|
||||
'## Tests / Verification',
|
||||
'- Pending',
|
||||
'',
|
||||
'## Follow-ups',
|
||||
'- Pending'
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
path: workerPlan.statusFilePath,
|
||||
content: [
|
||||
`# Status: ${workerPlan.workerName}`,
|
||||
'',
|
||||
'- State: not started',
|
||||
`- Worktree: \`${workerPlan.worktreePath}\``,
|
||||
`- Branch: \`${workerPlan.branchName}\``
|
||||
].join('\n')
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function buildOrchestrationPlan(config = {}) {
|
||||
const repoRoot = path.resolve(config.repoRoot || process.cwd());
|
||||
const repoName = path.basename(repoRoot);
|
||||
const workers = Array.isArray(config.workers) ? config.workers : [];
|
||||
const globalSeedPaths = normalizeSeedPaths(config.seedPaths, repoRoot);
|
||||
const sessionName = slugify(config.sessionName || repoName, 'session');
|
||||
const worktreeRoot = path.resolve(config.worktreeRoot || path.dirname(repoRoot));
|
||||
const coordinationRoot = path.resolve(
|
||||
config.coordinationRoot || path.join(repoRoot, '.orchestration')
|
||||
);
|
||||
const coordinationDir = path.join(coordinationRoot, sessionName);
|
||||
const baseRef = config.baseRef || 'HEAD';
|
||||
const defaultLauncher = config.launcherCommand || '';
|
||||
|
||||
if (workers.length === 0) {
|
||||
throw new Error('buildOrchestrationPlan requires at least one worker');
|
||||
}
|
||||
|
||||
const workerPlans = workers.map((worker, index) => {
|
||||
if (!worker || typeof worker.task !== 'string' || worker.task.trim().length === 0) {
|
||||
throw new Error(`Worker ${index + 1} is missing a task`);
|
||||
}
|
||||
|
||||
const workerName = worker.name || `worker-${index + 1}`;
|
||||
const workerSlug = slugify(workerName, `worker-${index + 1}`);
|
||||
const branchName = `orchestrator-${sessionName}-${workerSlug}`;
|
||||
const worktreePath = path.join(worktreeRoot, `${repoName}-${sessionName}-${workerSlug}`);
|
||||
const workerCoordinationDir = path.join(coordinationDir, workerSlug);
|
||||
const taskFilePath = path.join(workerCoordinationDir, 'task.md');
|
||||
const handoffFilePath = path.join(workerCoordinationDir, 'handoff.md');
|
||||
const statusFilePath = path.join(workerCoordinationDir, 'status.md');
|
||||
const launcherCommand = worker.launcherCommand || defaultLauncher;
|
||||
const workerSeedPaths = normalizeSeedPaths(worker.seedPaths, repoRoot);
|
||||
const seedPaths = normalizeSeedPaths([...globalSeedPaths, ...workerSeedPaths], repoRoot);
|
||||
const templateVariables = {
|
||||
branch_name: branchName,
|
||||
handoff_file: handoffFilePath,
|
||||
repo_root: repoRoot,
|
||||
session_name: sessionName,
|
||||
status_file: statusFilePath,
|
||||
task_file: taskFilePath,
|
||||
worker_name: workerName,
|
||||
worker_slug: workerSlug,
|
||||
worktree_path: worktreePath
|
||||
};
|
||||
|
||||
if (!launcherCommand) {
|
||||
throw new Error(`Worker ${workerName} is missing a launcherCommand`);
|
||||
}
|
||||
|
||||
const gitArgs = ['worktree', 'add', '-b', branchName, worktreePath, baseRef];
|
||||
|
||||
return {
|
||||
branchName,
|
||||
coordinationDir: workerCoordinationDir,
|
||||
gitArgs,
|
||||
gitCommand: formatCommand('git', gitArgs),
|
||||
handoffFilePath,
|
||||
launchCommand: renderTemplate(launcherCommand, templateVariables),
|
||||
repoRoot,
|
||||
sessionName,
|
||||
seedPaths,
|
||||
statusFilePath,
|
||||
task: worker.task.trim(),
|
||||
taskFilePath,
|
||||
workerName,
|
||||
workerSlug,
|
||||
worktreePath
|
||||
};
|
||||
});
|
||||
|
||||
const tmuxCommands = [
|
||||
{
|
||||
cmd: 'tmux',
|
||||
args: ['new-session', '-d', '-s', sessionName, '-n', 'orchestrator', '-c', repoRoot],
|
||||
description: 'Create detached tmux session'
|
||||
},
|
||||
{
|
||||
cmd: 'tmux',
|
||||
args: [
|
||||
'send-keys',
|
||||
'-t',
|
||||
sessionName,
|
||||
`printf '%s\\n' 'Session: ${sessionName}' 'Coordination: ${coordinationDir}'`,
|
||||
'C-m'
|
||||
],
|
||||
description: 'Print orchestrator session details'
|
||||
}
|
||||
];
|
||||
|
||||
for (const workerPlan of workerPlans) {
|
||||
tmuxCommands.push(
|
||||
{
|
||||
cmd: 'tmux',
|
||||
args: ['split-window', '-d', '-t', sessionName, '-c', workerPlan.worktreePath],
|
||||
description: `Create pane for ${workerPlan.workerName}`
|
||||
},
|
||||
{
|
||||
cmd: 'tmux',
|
||||
args: ['select-layout', '-t', sessionName, 'tiled'],
|
||||
description: 'Arrange panes in tiled layout'
|
||||
},
|
||||
{
|
||||
cmd: 'tmux',
|
||||
args: ['select-pane', '-t', '<pane-id>', '-T', workerPlan.workerSlug],
|
||||
description: `Label pane ${workerPlan.workerSlug}`
|
||||
},
|
||||
{
|
||||
cmd: 'tmux',
|
||||
args: [
|
||||
'send-keys',
|
||||
'-t',
|
||||
'<pane-id>',
|
||||
`cd ${shellQuote(workerPlan.worktreePath)} && ${workerPlan.launchCommand}`,
|
||||
'C-m'
|
||||
],
|
||||
description: `Launch worker ${workerPlan.workerName}`
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
baseRef,
|
||||
coordinationDir,
|
||||
replaceExisting: Boolean(config.replaceExisting),
|
||||
repoRoot,
|
||||
sessionName,
|
||||
tmuxCommands,
|
||||
workerPlans
|
||||
};
|
||||
}
|
||||
|
||||
function materializePlan(plan) {
|
||||
for (const workerPlan of plan.workerPlans) {
|
||||
const artifacts = buildWorkerArtifacts(workerPlan);
|
||||
fs.mkdirSync(artifacts.dir, { recursive: true });
|
||||
for (const file of artifacts.files) {
|
||||
fs.writeFileSync(file.path, file.content + '\n', 'utf8');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(program, args, options = {}) {
|
||||
const result = spawnSync(program, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const stderr = (result.stderr || '').trim();
|
||||
throw new Error(`${program} ${args.join(' ')} failed${stderr ? `: ${stderr}` : ''}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function commandSucceeds(program, args, options = {}) {
|
||||
const result = spawnSync(program, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function canonicalizePath(targetPath) {
|
||||
const resolvedPath = path.resolve(targetPath);
|
||||
|
||||
try {
|
||||
return fs.realpathSync.native(resolvedPath);
|
||||
} catch (error) {
|
||||
const parentPath = path.dirname(resolvedPath);
|
||||
|
||||
try {
|
||||
return path.join(fs.realpathSync.native(parentPath), path.basename(resolvedPath));
|
||||
} catch (parentError) {
|
||||
return resolvedPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function branchExists(repoRoot, branchName) {
|
||||
return commandSucceeds('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], {
|
||||
cwd: repoRoot
|
||||
});
|
||||
}
|
||||
|
||||
function listWorktrees(repoRoot) {
|
||||
const listed = runCommand('git', ['worktree', 'list', '--porcelain'], { cwd: repoRoot });
|
||||
const lines = (listed.stdout || '').split('\n');
|
||||
const worktrees = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
const listedPath = line.slice('worktree '.length).trim();
|
||||
worktrees.push({
|
||||
listedPath,
|
||||
canonicalPath: canonicalizePath(listedPath)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return worktrees;
|
||||
}
|
||||
|
||||
function cleanupExisting(plan) {
|
||||
runCommand('git', ['worktree', 'prune', '--expire', 'now'], { cwd: plan.repoRoot });
|
||||
|
||||
const hasSession = spawnSync('tmux', ['has-session', '-t', plan.sessionName], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
if (hasSession.status === 0) {
|
||||
runCommand('tmux', ['kill-session', '-t', plan.sessionName], { cwd: plan.repoRoot });
|
||||
}
|
||||
|
||||
for (const workerPlan of plan.workerPlans) {
|
||||
const expectedWorktreePath = canonicalizePath(workerPlan.worktreePath);
|
||||
const existingWorktree = listWorktrees(plan.repoRoot).find(
|
||||
worktree => worktree.canonicalPath === expectedWorktreePath
|
||||
);
|
||||
|
||||
if (existingWorktree) {
|
||||
runCommand('git', ['worktree', 'remove', '--force', existingWorktree.listedPath], {
|
||||
cwd: plan.repoRoot
|
||||
});
|
||||
}
|
||||
|
||||
if (fs.existsSync(workerPlan.worktreePath)) {
|
||||
fs.rmSync(workerPlan.worktreePath, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
runCommand('git', ['worktree', 'prune', '--expire', 'now'], { cwd: plan.repoRoot });
|
||||
|
||||
if (branchExists(plan.repoRoot, workerPlan.branchName)) {
|
||||
runCommand('git', ['branch', '-D', workerPlan.branchName], { cwd: plan.repoRoot });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function executePlan(plan) {
|
||||
runCommand('git', ['rev-parse', '--is-inside-work-tree'], { cwd: plan.repoRoot });
|
||||
runCommand('tmux', ['-V']);
|
||||
|
||||
if (plan.replaceExisting) {
|
||||
cleanupExisting(plan);
|
||||
} else {
|
||||
const hasSession = spawnSync('tmux', ['has-session', '-t', plan.sessionName], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
if (hasSession.status === 0) {
|
||||
throw new Error(`tmux session already exists: ${plan.sessionName}`);
|
||||
}
|
||||
}
|
||||
|
||||
materializePlan(plan);
|
||||
|
||||
for (const workerPlan of plan.workerPlans) {
|
||||
runCommand('git', workerPlan.gitArgs, { cwd: plan.repoRoot });
|
||||
overlaySeedPaths({
|
||||
repoRoot: plan.repoRoot,
|
||||
seedPaths: workerPlan.seedPaths,
|
||||
worktreePath: workerPlan.worktreePath
|
||||
});
|
||||
}
|
||||
|
||||
runCommand(
|
||||
'tmux',
|
||||
['new-session', '-d', '-s', plan.sessionName, '-n', 'orchestrator', '-c', plan.repoRoot],
|
||||
{ cwd: plan.repoRoot }
|
||||
);
|
||||
runCommand(
|
||||
'tmux',
|
||||
[
|
||||
'send-keys',
|
||||
'-t',
|
||||
plan.sessionName,
|
||||
`printf '%s\\n' 'Session: ${plan.sessionName}' 'Coordination: ${plan.coordinationDir}'`,
|
||||
'C-m'
|
||||
],
|
||||
{ cwd: plan.repoRoot }
|
||||
);
|
||||
|
||||
for (const workerPlan of plan.workerPlans) {
|
||||
const splitResult = runCommand(
|
||||
'tmux',
|
||||
['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', plan.sessionName, '-c', workerPlan.worktreePath],
|
||||
{ cwd: plan.repoRoot }
|
||||
);
|
||||
const paneId = splitResult.stdout.trim();
|
||||
|
||||
if (!paneId) {
|
||||
throw new Error(`tmux split-window did not return a pane id for ${workerPlan.workerName}`);
|
||||
}
|
||||
|
||||
runCommand('tmux', ['select-layout', '-t', plan.sessionName, 'tiled'], { cwd: plan.repoRoot });
|
||||
runCommand('tmux', ['select-pane', '-t', paneId, '-T', workerPlan.workerSlug], {
|
||||
cwd: plan.repoRoot
|
||||
});
|
||||
runCommand(
|
||||
'tmux',
|
||||
[
|
||||
'send-keys',
|
||||
'-t',
|
||||
paneId,
|
||||
`cd ${shellQuote(workerPlan.worktreePath)} && ${workerPlan.launchCommand}`,
|
||||
'C-m'
|
||||
],
|
||||
{ cwd: plan.repoRoot }
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
coordinationDir: plan.coordinationDir,
|
||||
sessionName: plan.sessionName,
|
||||
workerCount: plan.workerPlans.length
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildOrchestrationPlan,
|
||||
executePlan,
|
||||
materializePlan,
|
||||
normalizeSeedPaths,
|
||||
overlaySeedPaths,
|
||||
renderTemplate,
|
||||
slugify
|
||||
};
|
||||
92
scripts/orchestrate-codex-worker.sh
Executable file
92
scripts/orchestrate-codex-worker.sh
Executable file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 3 ]]; then
|
||||
echo "Usage: bash scripts/orchestrate-codex-worker.sh <task-file> <handoff-file> <status-file>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
task_file="$1"
|
||||
handoff_file="$2"
|
||||
status_file="$3"
|
||||
|
||||
timestamp() {
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
||||
}
|
||||
|
||||
write_status() {
|
||||
local state="$1"
|
||||
local details="$2"
|
||||
|
||||
cat > "$status_file" <<EOF
|
||||
# Status
|
||||
|
||||
- State: $state
|
||||
- Updated: $(timestamp)
|
||||
- Branch: $(git rev-parse --abbrev-ref HEAD)
|
||||
- Worktree: \`$(pwd)\`
|
||||
|
||||
$details
|
||||
EOF
|
||||
}
|
||||
|
||||
mkdir -p "$(dirname "$handoff_file")" "$(dirname "$status_file")"
|
||||
write_status "running" "- Task file: \`$task_file\`"
|
||||
|
||||
prompt_file="$(mktemp)"
|
||||
output_file="$(mktemp)"
|
||||
cleanup() {
|
||||
rm -f "$prompt_file" "$output_file"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
cat > "$prompt_file" <<EOF
|
||||
You are one worker in an ECC tmux/worktree swarm.
|
||||
|
||||
Rules:
|
||||
- Work only in the current git worktree.
|
||||
- Do not touch sibling worktrees or the parent repo checkout.
|
||||
- Complete the task from the task file below.
|
||||
- Do not spawn subagents or external agents for this task.
|
||||
- Report progress and final results in stdout only.
|
||||
- Do not write handoff or status files yourself; the launcher manages those artifacts.
|
||||
- If you change code or docs, keep the scope narrow and defensible.
|
||||
- In your final response, include exactly these sections:
|
||||
1. Summary
|
||||
2. Files Changed
|
||||
3. Validation
|
||||
4. Remaining Risks
|
||||
|
||||
Task file: $task_file
|
||||
|
||||
$(cat "$task_file")
|
||||
EOF
|
||||
|
||||
if codex exec -p yolo -m gpt-5.4 --color never -C "$(pwd)" -o "$output_file" - < "$prompt_file"; then
|
||||
{
|
||||
echo "# Handoff"
|
||||
echo
|
||||
echo "- Completed: $(timestamp)"
|
||||
echo "- Branch: \`$(git rev-parse --abbrev-ref HEAD)\`"
|
||||
echo "- Worktree: \`$(pwd)\`"
|
||||
echo
|
||||
cat "$output_file"
|
||||
echo
|
||||
echo "## Git Status"
|
||||
echo
|
||||
git status --short
|
||||
} > "$handoff_file"
|
||||
write_status "completed" "- Handoff file: \`$handoff_file\`"
|
||||
else
|
||||
{
|
||||
echo "# Handoff"
|
||||
echo
|
||||
echo "- Failed: $(timestamp)"
|
||||
echo "- Branch: \`$(git rev-parse --abbrev-ref HEAD)\`"
|
||||
echo "- Worktree: \`$(pwd)\`"
|
||||
echo
|
||||
echo "The Codex worker exited with a non-zero status."
|
||||
} > "$handoff_file"
|
||||
write_status "failed" "- Handoff file: \`$handoff_file\`"
|
||||
exit 1
|
||||
fi
|
||||
108
scripts/orchestrate-worktrees.js
Normal file
108
scripts/orchestrate-worktrees.js
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
buildOrchestrationPlan,
|
||||
executePlan,
|
||||
materializePlan
|
||||
} = require('./lib/tmux-worktree-orchestrator');
|
||||
|
||||
function usage() {
|
||||
console.log([
|
||||
'Usage:',
|
||||
' node scripts/orchestrate-worktrees.js <plan.json> [--execute]',
|
||||
' node scripts/orchestrate-worktrees.js <plan.json> [--write-only]',
|
||||
'',
|
||||
'Placeholders supported in launcherCommand:',
|
||||
' {worker_name} {worker_slug} {session_name} {repo_root}',
|
||||
' {worktree_path} {branch_name} {task_file} {handoff_file} {status_file}',
|
||||
'',
|
||||
'Without flags the script prints a dry-run plan only.'
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const planPath = args.find(arg => !arg.startsWith('--'));
|
||||
return {
|
||||
execute: args.includes('--execute'),
|
||||
planPath,
|
||||
writeOnly: args.includes('--write-only')
|
||||
};
|
||||
}
|
||||
|
||||
function loadPlanConfig(planPath) {
|
||||
const absolutePath = path.resolve(planPath);
|
||||
const raw = fs.readFileSync(absolutePath, 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
config.repoRoot = config.repoRoot || process.cwd();
|
||||
return { absolutePath, config };
|
||||
}
|
||||
|
||||
function printDryRun(plan, absolutePath) {
|
||||
const preview = {
|
||||
planFile: absolutePath,
|
||||
sessionName: plan.sessionName,
|
||||
repoRoot: plan.repoRoot,
|
||||
coordinationDir: plan.coordinationDir,
|
||||
workers: plan.workerPlans.map(worker => ({
|
||||
workerName: worker.workerName,
|
||||
branchName: worker.branchName,
|
||||
worktreePath: worker.worktreePath,
|
||||
seedPaths: worker.seedPaths,
|
||||
taskFilePath: worker.taskFilePath,
|
||||
handoffFilePath: worker.handoffFilePath,
|
||||
launchCommand: worker.launchCommand
|
||||
})),
|
||||
commands: [
|
||||
...plan.workerPlans.map(worker => worker.gitCommand),
|
||||
...plan.tmuxCommands.map(command => [command.cmd, ...command.args].join(' '))
|
||||
]
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(preview, null, 2));
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { execute, planPath, writeOnly } = parseArgs(process.argv);
|
||||
|
||||
if (!planPath) {
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { absolutePath, config } = loadPlanConfig(planPath);
|
||||
const plan = buildOrchestrationPlan(config);
|
||||
|
||||
if (writeOnly) {
|
||||
materializePlan(plan);
|
||||
console.log(`Wrote orchestration files to ${plan.coordinationDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!execute) {
|
||||
printDryRun(plan, absolutePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = executePlan(plan);
|
||||
console.log([
|
||||
`Started tmux session '${result.sessionName}' with ${result.workerCount} worker panes.`,
|
||||
`Coordination files: ${result.coordinationDir}`,
|
||||
`Attach with: tmux attach -t ${result.sessionName}`
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(`[orchestrate-worktrees] ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { main };
|
||||
59
scripts/orchestration-status.js
Normal file
59
scripts/orchestration-status.js
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { collectSessionSnapshot } = require('./lib/orchestration-session');
|
||||
|
||||
function usage() {
|
||||
console.log([
|
||||
'Usage:',
|
||||
' node scripts/orchestration-status.js <session-name|plan.json> [--write <output.json>]',
|
||||
'',
|
||||
'Examples:',
|
||||
' node scripts/orchestration-status.js workflow-visual-proof',
|
||||
' node scripts/orchestration-status.js .claude/plan/workflow-visual-proof.json',
|
||||
' node scripts/orchestration-status.js .claude/plan/workflow-visual-proof.json --write /tmp/snapshot.json'
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const target = args.find(arg => !arg.startsWith('--'));
|
||||
const writeIndex = args.indexOf('--write');
|
||||
const writePath = writeIndex >= 0 ? args[writeIndex + 1] : null;
|
||||
|
||||
return { target, writePath };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { target, writePath } = parseArgs(process.argv);
|
||||
|
||||
if (!target) {
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const snapshot = collectSessionSnapshot(target, process.cwd());
|
||||
const json = JSON.stringify(snapshot, null, 2);
|
||||
|
||||
if (writePath) {
|
||||
const absoluteWritePath = path.resolve(writePath);
|
||||
fs.mkdirSync(path.dirname(absoluteWritePath), { recursive: true });
|
||||
fs.writeFileSync(absoluteWritePath, json + '\n', 'utf8');
|
||||
}
|
||||
|
||||
console.log(json);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(`[orchestration-status] ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { main };
|
||||
Reference in New Issue
Block a user