feat: add github-native coordination (epic-* commands + scripts + tests)

Adds a GitHub-native coordination layer on top of ECC:

Commands (7 new slash commands):
- epic-claim, epic-sync, epic-validate, epic-publish
- epic-review, epic-unblock, epic-decompose

Scripts:
- scripts/github-coordination.js  — CLI entry point
- scripts/lib/github-coordination.js  — core library (state machine, gh API wrappers)
- scripts/status.js  — coordination status reporter

Config:
- config/github-native-coordination.json  — labels, review policy, validation gates

Tests:
- tests/lib/github-coordination.test.js  — 15 unit tests for pure functions
- tests/scripts/github-coordination.test.js  — integration/CLI test suite

Registry:
- docs/COMMAND-REGISTRY.json  — adds 7 epic-* entries, totalCommands 84 → 91

No encoding changes, no prp-* modifications, no Windows shims.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Victor Casado
2026-06-11 12:58:11 -04:00
parent fec84fcf19
commit 64470f4307
14 changed files with 2175 additions and 1 deletions
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env node
'use strict';
const os = require('os');
const {
applyClaim,
applyDecompose,
applyPublish,
applyReview,
applySync,
applyUnblock,
applyValidate,
formatCollection,
formatSummary,
loadPolicy,
normalizeIssueNumber,
openStore,
} = require('./lib/github-coordination');
function usage(exitCode = 0) {
console.log([
'Usage: node scripts/github-coordination.js <command> [options]',
'',
'Commands:',
' claim <issue-number> Claim an epic issue and stamp coordination state',
' sync Sync epic issue bodies, labels, and local snapshots',
' validate <issue-number> Validate epic readiness and dependency status',
' publish <issue-number> Publish a validated epic update/comment',
' review <issue-number> Mark review requested/approved/blocked',
' unblock Sweep blocked epics whose dependencies are closed',
' decompose <issue-number> Reconcile epic task breakdown from issue body',
'',
'Options:',
' --repo <owner/repo> GitHub repository',
' --issue <number> Issue number for actions that target one issue',
' --actor <login> Claim owner / coordination actor',
' --branch <name> Epic branch name to stamp into the coordination body',
' --config <path> Optional coordination policy config',
' --db <path> SQLite state store path',
' --home <dir> Override home directory used by the state store',
' --limit <n> Limit issues scanned by sync/unblock',
' --dry-run Preview changes without modifying GitHub or state',
' --json Emit machine-readable JSON',
' --help, -h Show this help',
].join('\n'));
process.exit(exitCode);
}
function readValue(args, index, flagName) {
const value = args[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${flagName} requires a value`);
}
return value;
}
function parseArgs(argv) {
const args = argv.slice(2);
const parsed = {
command: null,
actor: null,
branch: null,
configPath: null,
dbPath: null,
dryRun: false,
help: false,
homeDir: null,
issueNumber: null,
json: false,
limit: 100,
repo: null,
validation: null,
review: null,
status: null,
projectState: null,
positionals: [],
};
if (args.length > 0 && !args[0].startsWith('-')) {
parsed.command = args.shift();
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--help' || arg === '-h') {
parsed.help = true;
continue;
}
if (arg === '--json') {
parsed.json = true;
continue;
}
if (arg === '--dry-run') {
parsed.dryRun = true;
continue;
}
if (arg === '--repo') {
parsed.repo = readValue(args, index, arg);
index += 1;
continue;
}
if (arg === '--issue') {
parsed.issueNumber = normalizeIssueNumber(readValue(args, index, arg));
index += 1;
continue;
}
if (arg === '--actor') {
parsed.actor = readValue(args, index, arg);
index += 1;
continue;
}
if (arg === '--branch') {
parsed.branch = readValue(args, index, arg);
index += 1;
continue;
}
if (arg === '--config') {
parsed.configPath = readValue(args, index, arg);
index += 1;
continue;
}
if (arg === '--db') {
parsed.dbPath = readValue(args, index, arg);
index += 1;
continue;
}
if (arg === '--home') {
parsed.homeDir = readValue(args, index, arg);
index += 1;
continue;
}
if (arg === '--limit') {
parsed.limit = normalizeIssueNumber(readValue(args, index, arg));
index += 1;
continue;
}
if (arg === '--validation') {
parsed.validation = readValue(args, index, arg);
index += 1;
continue;
}
if (arg === '--review') {
parsed.review = readValue(args, index, arg);
index += 1;
continue;
}
if (arg === '--status') {
parsed.status = readValue(args, index, arg);
index += 1;
continue;
}
if (arg === '--project-state') {
parsed.projectState = readValue(args, index, arg);
index += 1;
continue;
}
if (!arg.startsWith('-')) {
parsed.positionals.push(arg);
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
if (!parsed.command) {
parsed.command = 'sync';
}
if (!parsed.issueNumber && parsed.positionals.length > 0) {
parsed.issueNumber = normalizeIssueNumber(parsed.positionals[0]);
}
return parsed;
}
async function main() {
let store = null;
try {
const options = parseArgs(process.argv);
if (options.help) {
usage(0);
}
if (!options.repo) {
throw new Error('Missing --repo <owner/repo>.');
}
const policy = loadPolicy(process.cwd(), options.configPath);
store = await openStore({
dbPath: options.dbPath,
homeDir: options.homeDir || process.env.HOME || os.homedir(),
});
let payload;
if (options.command === 'claim') {
if (!options.issueNumber) throw new Error('Missing issue number.');
payload = applyClaim(options.repo, options.issueNumber, {
actor: options.actor,
branch: options.branch,
configPath: options.configPath,
dryRun: options.dryRun,
owner: options.actor,
projectState: options.projectState,
review: options.review,
status: options.status,
validation: options.validation,
}, { store, policy, rootDir: process.cwd() });
} else if (options.command === 'sync') {
payload = applySync(options.repo, {
configPath: options.configPath,
dryRun: options.dryRun,
limit: options.limit,
}, { store, policy, rootDir: process.cwd() });
} else if (options.command === 'validate') {
if (!options.issueNumber) throw new Error('Missing issue number.');
payload = applyValidate(options.repo, options.issueNumber, {
configPath: options.configPath,
dryRun: options.dryRun,
}, { store, policy, rootDir: process.cwd() });
} else if (options.command === 'publish') {
if (!options.issueNumber) throw new Error('Missing issue number.');
payload = applyPublish(options.repo, options.issueNumber, {
configPath: options.configPath,
dryRun: options.dryRun,
}, { store, policy, rootDir: process.cwd() });
} else if (options.command === 'review') {
if (!options.issueNumber) throw new Error('Missing issue number.');
payload = applyReview(options.repo, options.issueNumber, {
configPath: options.configPath,
dryRun: options.dryRun,
review: options.review,
}, { store, policy, rootDir: process.cwd() });
} else if (options.command === 'unblock') {
payload = applyUnblock(options.repo, {
configPath: options.configPath,
dryRun: options.dryRun,
limit: options.limit,
}, { store, policy, rootDir: process.cwd() });
} else if (options.command === 'decompose') {
if (!options.issueNumber) throw new Error('Missing issue number.');
payload = applyDecompose(options.repo, options.issueNumber, {
configPath: options.configPath,
dryRun: options.dryRun,
}, { store, policy, rootDir: process.cwd() });
} else {
throw new Error(`Unknown command: ${options.command}`);
}
if (options.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
} else if (options.command === 'sync' || options.command === 'unblock') {
process.stdout.write(formatCollection(payload));
} else {
process.stdout.write(formatSummary(payload));
}
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
} finally {
if (store) {
store.close();
}
}
}
if (require.main === module) {
main();
}
module.exports = {
main,
parseArgs,
usage,
};
File diff suppressed because it is too large Load Diff
+102
View File
@@ -165,6 +165,74 @@ function printWorkItems(section) {
}
}
function summarizeGithubCoordination(workItems) {
const epicItems = workItems.items.filter(item => item.source === 'github-epic');
const summary = {
totalCount: epicItems.length,
availableCount: 0,
claimedCount: 0,
readyCount: 0,
blockedCount: 0,
validatedCount: 0,
publishedCount: 0,
recent: epicItems.slice(0, 10),
};
for (const item of epicItems) {
const state = item.metadata && item.metadata.coordination ? item.metadata.coordination.status : item.status;
switch (state) {
case 'available':
summary.availableCount += 1;
break;
case 'claimed':
summary.claimedCount += 1;
break;
case 'ready':
summary.readyCount += 1;
break;
case 'blocked':
summary.blockedCount += 1;
break;
case 'validated':
summary.validatedCount += 1;
break;
case 'published':
summary.publishedCount += 1;
break;
default:
summary.availableCount += 1;
break;
}
}
return summary;
}
function printGithubCoordination(section) {
console.log(`GitHub epic coordination: ${section.totalCount} tracked`);
if (section.totalCount === 0) {
console.log(' - none');
return;
}
console.log(` Available: ${section.availableCount}`);
console.log(` Claimed: ${section.claimedCount}`);
console.log(` Ready: ${section.readyCount}`);
console.log(` Blocked: ${section.blockedCount}`);
console.log(` Validated: ${section.validatedCount}`);
console.log(` Published: ${section.publishedCount}`);
for (const item of section.recent) {
console.log(` - ${item.source}/${item.sourceId || item.id} ${item.status}: ${item.title}`);
if (item.metadata && item.metadata.coordination) {
const coordination = item.metadata.coordination;
console.log(` Epic status: ${coordination.status}`);
console.log(` Owner: ${coordination.owner || '(unassigned)'}`);
console.log(` Branch: ${coordination.branch || '(none)'}`);
}
}
}
function printReadiness(section) {
console.log(`Readiness: ${section.status}`);
console.log(` Attention items: ${section.attentionCount}`);
@@ -188,6 +256,10 @@ function printHuman(payload) {
console.log();
printGovernance(payload.governance);
console.log();
if (payload.githubCoordination) {
printGithubCoordination(payload.githubCoordination);
console.log();
}
printWorkItems(payload.workItems);
}
@@ -318,6 +390,35 @@ function renderMarkdown(payload) {
}
}
if (payload.githubCoordination) {
lines.push(
'',
'## GitHub Epic Coordination',
'',
`Tracked: ${payload.githubCoordination.totalCount}`,
`Available: ${payload.githubCoordination.availableCount}`,
`Claimed: ${payload.githubCoordination.claimedCount}`,
`Ready: ${payload.githubCoordination.readyCount}`,
`Blocked: ${payload.githubCoordination.blockedCount}`,
`Validated: ${payload.githubCoordination.validatedCount}`,
`Published: ${payload.githubCoordination.publishedCount}`
);
if (payload.githubCoordination.recent.length === 0) {
lines.push('', '- none');
} else {
lines.push('', 'Recent epics:');
for (const item of payload.githubCoordination.recent) {
lines.push(`- ${formatCode(item.source)} ${formatCode(item.sourceId || item.id)} ${item.status}: ${item.title}`);
if (item.metadata && item.metadata.coordination) {
lines.push(` - Epic status: ${item.metadata.coordination.status}`);
lines.push(` - Owner: ${item.metadata.coordination.owner || '(unassigned)'}`);
lines.push(` - Branch: ${item.metadata.coordination.branch || '(none)'}`);
}
}
}
}
return `${lines.join('\n')}\n`;
}
@@ -350,6 +451,7 @@ async function main() {
workItemLimit: options.limit,
}),
};
payload.githubCoordination = summarizeGithubCoordination(payload.workItems);
if (options.json) {
const output = `${JSON.stringify(payload, null, 2)}\n`;