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

26
commands/epic-claim.md Normal file
View File

@ -0,0 +1,26 @@
---
description: Claim an epic issue, stamp coordination state, and sync local ownership.
---
# /epic-claim
Claim one epic issue as the source of truth for a unit of work.
Use the coordination script:
```bash
node scripts/github-coordination.js claim <issue-number> --repo <owner/repo> --actor <login>
```
What this does:
1. Loads the issue body and coordination block.
2. Marks the epic as claimed in GitHub issue state.
3. Updates labels and the local SQLite cache.
4. Appends an audit comment for the claim.
Compatibility aliases:
- `/orch-add-feature`
- `/orch-change-feature`
- `/prp-implement`

View File

@ -0,0 +1,23 @@
---
description: Break an epic into task children without creating task branches.
---
# /epic-decompose
Reconcile the task breakdown for one epic issue.
```bash
node scripts/github-coordination.js decompose <issue-number> --repo <owner/repo>
```
What this does:
1. Reads the epic issue body for task checklists and dependency references.
2. Stores the decomposition in the coordination block.
3. Leaves task branches out of the workflow.
4. Appends a concise audit comment.
Compatibility aliases:
- `/plan`
- `/prp-plan`

23
commands/epic-publish.md Normal file
View File

@ -0,0 +1,23 @@
---
description: Publish a validated epic update back to the issue and local cache.
---
# /epic-publish
Publish a validated coordination update to GitHub.
```bash
node scripts/github-coordination.js publish <issue-number> --repo <owner/repo>
```
What this does:
1. Re-validates the epic before publishing.
2. Updates the coordination block in the issue body.
3. Appends a concise publish comment.
4. Records the final local snapshot.
Compatibility aliases:
- `/pr`
- `/prp-pr`

23
commands/epic-review.md Normal file
View File

@ -0,0 +1,23 @@
---
description: Mark epic review requested, approved, or changes requested.
---
# /epic-review
Coordinate review state for an epic issue.
```bash
node scripts/github-coordination.js review <issue-number> --repo <owner/repo> --review approved
```
What this does:
1. Updates the review state in the coordination block.
2. Syncs review labels to GitHub.
3. Records the review outcome in an audit comment.
4. Keeps the local cache aligned with the issue body.
Compatibility aliases:
- `/review-pr`
- `/code-review`

23
commands/epic-sync.md Normal file
View File

@ -0,0 +1,23 @@
---
description: Sync epic issue bodies, labels, and local coordination snapshots from GitHub.
---
# /epic-sync
Run a deterministic sync for epic issues.
```bash
node scripts/github-coordination.js sync --repo <owner/repo>
```
What this does:
1. Reads issue bodies as the canonical epic state.
2. Reconciles the coordination block with labels.
3. Writes a fresh local snapshot for each epic issue.
4. Keeps the SQLite cache aligned with GitHub.
Compatibility aliases:
- `/projects`
- `/work-items sync-github`

22
commands/epic-unblock.md Normal file
View File

@ -0,0 +1,22 @@
---
description: Sweep blocked epic issues and reopen anything whose dependencies are closed.
---
# /epic-unblock
Sweep blocked epics whose declared dependencies are complete.
```bash
node scripts/github-coordination.js unblock --repo <owner/repo>
```
What this does:
1. Scans epic issues in the repository.
2. Checks each blocked epic's dependency list.
3. Moves fully unblocked epics to ready.
4. Updates labels, comments, and local snapshots.
Compatibility aliases:
- `/loop-status`

22
commands/epic-validate.md Normal file
View File

@ -0,0 +1,22 @@
---
description: Validate epic readiness, dependencies, and coordination policy.
---
# /epic-validate
Validate a single epic issue before publishing or review handoff.
```bash
node scripts/github-coordination.js validate <issue-number> --repo <owner/repo>
```
What this checks:
1. Coordination state exists and is parseable.
2. Validation state is satisfied by policy.
3. Declared dependencies are closed.
4. The epic is ready for the next workflow stage.
Compatibility aliases:
- `/quality-gate`

View File

@ -0,0 +1,38 @@
{
"schemaVersion": "ecc.github.coordination.v1",
"sectionMarker": "ecc-coordination",
"labels": {
"epic": "epic",
"available": "coordination:available",
"claimed": "coordination:claimed",
"ready": "coordination:ready",
"blocked": "coordination:blocked",
"validated": "coordination:validated",
"reviewRequested": "coordination:review-requested",
"reviewApproved": "coordination:review-approved",
"reviewChangesRequested": "coordination:review-changes-requested",
"published": "coordination:published",
"synced": "coordination:synced"
},
"review": {
"required": true,
"defaultMode": "required"
},
"validation": {
"required": true
},
"branchModel": {
"epicOnly": true,
"taskBranches": false
},
"project": {
"enabled": false,
"fieldNames": {
"status": "Status",
"owner": "Owner",
"branch": "Branch",
"validation": "Validation",
"review": "Review"
}
}
}

View File

@ -1,6 +1,6 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"totalCommands": 84, "totalCommands": 91,
"commands": [ "commands": [
{ {
"command": "aside", "command": "aside",
@ -111,6 +111,72 @@
], ],
"path": "commands/ecc-guide.md" "path": "commands/ecc-guide.md"
}, },
{
"command": "epic-claim",
"description": "Claim an epic issue, stamp coordination state, and sync local ownership.",
"type": "review",
"primaryAgents": [],
"allAgents": [],
"skills": [
"orch-add-feature",
"orch-change-feature"
],
"path": "commands/epic-claim.md"
},
{
"command": "epic-decompose",
"description": "Break an epic into task children without creating task branches.",
"type": "review",
"primaryAgents": [],
"allAgents": [],
"skills": [],
"path": "commands/epic-decompose.md"
},
{
"command": "epic-publish",
"description": "Publish a validated epic update back to the issue and local cache.",
"type": "general",
"primaryAgents": [],
"allAgents": [],
"skills": [],
"path": "commands/epic-publish.md"
},
{
"command": "epic-review",
"description": "Mark epic review requested, approved, or changes requested.",
"type": "review",
"primaryAgents": [],
"allAgents": [],
"skills": [],
"path": "commands/epic-review.md"
},
{
"command": "epic-sync",
"description": "Sync epic issue bodies, labels, and local coordination snapshots from GitHub.",
"type": "general",
"primaryAgents": [],
"allAgents": [],
"skills": [],
"path": "commands/epic-sync.md"
},
{
"command": "epic-unblock",
"description": "Sweep blocked epic issues and reopen anything whose dependencies are closed.",
"type": "general",
"primaryAgents": [],
"allAgents": [],
"skills": [],
"path": "commands/epic-unblock.md"
},
{
"command": "epic-validate",
"description": "Validate epic readiness, dependencies, and coordination policy.",
"type": "review",
"primaryAgents": [],
"allAgents": [],
"skills": [],
"path": "commands/epic-validate.md"
},
{ {
"command": "evolve", "command": "evolve",
"description": "Analyze instincts and suggest or generate evolved structures", "description": "Analyze instincts and suggest or generate evolved structures",

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

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) { function printReadiness(section) {
console.log(`Readiness: ${section.status}`); console.log(`Readiness: ${section.status}`);
console.log(` Attention items: ${section.attentionCount}`); console.log(` Attention items: ${section.attentionCount}`);
@ -188,6 +256,10 @@ function printHuman(payload) {
console.log(); console.log();
printGovernance(payload.governance); printGovernance(payload.governance);
console.log(); console.log();
if (payload.githubCoordination) {
printGithubCoordination(payload.githubCoordination);
console.log();
}
printWorkItems(payload.workItems); 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`; return `${lines.join('\n')}\n`;
} }
@ -350,6 +451,7 @@ async function main() {
workItemLimit: options.limit, workItemLimit: options.limit,
}), }),
}; };
payload.githubCoordination = summarizeGithubCoordination(payload.workItems);
if (options.json) { if (options.json) {
const output = `${JSON.stringify(payload, null, 2)}\n`; const output = `${JSON.stringify(payload, null, 2)}\n`;

View File

@ -0,0 +1,249 @@
'use strict';
const assert = require('assert');
const {
normalizeRepo,
extractCoordinationState,
buildIssueStateFromAction,
desiredLabelsForState,
extractTasks,
renderCoordinationState,
DEFAULT_POLICY,
} = require('../../scripts/lib/github-coordination');
async function test(name, fn) {
try {
await fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
async function runTests() {
console.log('\n=== Testing github-coordination ===\n');
let passed = 0;
let failed = 0;
// normalizeRepo
if (await test('normalizeRepo returns { owner, name } for "owner/repo"', () => {
const result = normalizeRepo('acme/my-repo');
assert.deepStrictEqual(result, { owner: 'acme', name: 'my-repo' });
})) passed += 1; else failed += 1;
if (await test('normalizeRepo throws on "owner/repo/extra"', () => {
assert.throws(
() => normalizeRepo('owner/repo/extra'),
/Invalid repo format/
);
})) passed += 1; else failed += 1;
if (await test('normalizeRepo throws on bare string with no slash', () => {
assert.throws(
() => normalizeRepo('justowner'),
/Invalid repo format/
);
})) passed += 1; else failed += 1;
// extractCoordinationState
if (await test('extractCoordinationState returns null for body with no coordination section', () => {
const result = extractCoordinationState('## Some issue\n\nJust text, no coordination block.');
assert.strictEqual(result, null);
})) passed += 1; else failed += 1;
if (await test('extractCoordinationState returns parsed state from a proper coordination JSON block', () => {
const state = { schemaVersion: 'ecc.github.coordination.v1', kind: 'epic', status: 'available' };
const body = [
'<!-- ecc-coordination:start -->',
'```json',
JSON.stringify(state, null, 2),
'```',
'<!-- ecc-coordination:end -->',
].join('\n');
const result = extractCoordinationState(body);
assert.ok(result !== null);
assert.strictEqual(result.status, 'available');
assert.strictEqual(result.kind, 'epic');
assert.strictEqual(result.schemaVersion, 'ecc.github.coordination.v1');
})) passed += 1; else failed += 1;
if (await test('extractCoordinationState returns null when JSON block is malformed', () => {
const body = [
'<!-- ecc-coordination:start -->',
'```json',
'{ not valid json }',
'```',
'<!-- ecc-coordination:end -->',
].join('\n');
const result = extractCoordinationState(body);
assert.strictEqual(result, null);
})) passed += 1; else failed += 1;
// buildIssueStateFromAction
if (await test('buildIssueStateFromAction with "claim" action sets status, owner, branch, lastAction, lastActionAt', () => {
const issue = { number: 1, body: '', labels: [] };
const currentState = {
schemaVersion: DEFAULT_POLICY.schemaVersion,
status: 'available',
owner: null,
branch: null,
validation: 'pending',
review: 'not-requested',
dependencies: [],
tasks: [],
};
const before = new Date();
const result = buildIssueStateFromAction(issue, currentState, 'claim', {
owner: 'alice',
branch: 'feat/my-branch',
status: 'claimed',
});
const after = new Date();
assert.strictEqual(result.status, 'claimed');
assert.strictEqual(result.owner, 'alice');
assert.strictEqual(result.branch, 'feat/my-branch');
assert.strictEqual(result.lastAction, 'claim');
assert.ok(result.lastActionAt);
const actionAt = new Date(result.lastActionAt);
assert.ok(actionAt >= before && actionAt <= after);
})) passed += 1; else failed += 1;
if (await test('buildIssueStateFromAction with "unblock" action preserves owner from existing state', () => {
const issue = { number: 2, body: '', labels: [] };
const currentState = {
schemaVersion: DEFAULT_POLICY.schemaVersion,
status: 'blocked',
owner: 'bob',
branch: 'feat/blocked-branch',
validation: 'pending',
review: 'not-requested',
dependencies: [],
tasks: [],
};
const result = buildIssueStateFromAction(issue, currentState, 'unblock', {
status: 'ready',
});
assert.strictEqual(result.status, 'ready');
assert.strictEqual(result.owner, 'bob');
assert.strictEqual(result.branch, 'feat/blocked-branch');
assert.strictEqual(result.lastAction, 'unblock');
})) passed += 1; else failed += 1;
// desiredLabelsForState
if (await test('desiredLabelsForState for status "available" includes "coordination:available"', () => {
const labels = desiredLabelsForState({ status: 'available' });
assert.ok(Array.isArray(labels));
assert.ok(labels.includes('coordination:available'), `Expected coordination:available in [${labels.join(', ')}]`);
})) passed += 1; else failed += 1;
if (await test('desiredLabelsForState for status "claimed" includes "coordination:claimed" but not "coordination:available"', () => {
const labels = desiredLabelsForState({ status: 'claimed' });
assert.ok(labels.includes('coordination:claimed'), `Expected coordination:claimed in [${labels.join(', ')}]`);
assert.ok(!labels.includes('coordination:available'), `Did not expect coordination:available in [${labels.join(', ')}]`);
})) passed += 1; else failed += 1;
// extractTasks
if (await test('extractTasks returns empty array when body has no Tasks section', () => {
const body = 'Some issue without any task list.';
const tasks = extractTasks(body);
assert.deepStrictEqual(tasks, []);
})) passed += 1; else failed += 1;
if (await test('extractTasks parses completed and open checkboxes under ## Tasks heading', () => {
const body = [
'## Tasks',
'- [x] Done task',
'- [ ] Open task',
'- [x] Another done task',
].join('\n');
const tasks = extractTasks(body);
const completed = tasks.filter(t => t.done);
const open = tasks.filter(t => !t.done);
assert.strictEqual(tasks.length, 3);
assert.strictEqual(completed.length, 2);
assert.strictEqual(open.length, 1);
assert.strictEqual(open[0].title, 'Open task');
})) passed += 1; else failed += 1;
if (await test('extractTasks stops parsing at next heading after task section', () => {
const body = [
'## Tasks',
'- [x] First task',
'## Notes',
'- [ ] This is not a task',
].join('\n');
const tasks = extractTasks(body);
assert.strictEqual(tasks.length, 1);
assert.strictEqual(tasks[0].title, 'First task');
})) passed += 1; else failed += 1;
// renderCoordinationState
if (await test('renderCoordinationState returns a string containing the section marker', () => {
const state = {
schemaVersion: 'ecc.github.coordination.v1',
kind: 'epic',
status: 'available',
owner: null,
branch: null,
validation: 'pending',
review: 'not-requested',
project: { state: 'backlog', fields: {} },
dependencies: [],
tasks: [],
labels: [],
lastAction: 'sync',
lastActionAt: '2026-01-01T00:00:00.000Z',
lastSyncAt: '2026-01-01T00:00:00.000Z',
notes: null,
};
const rendered = renderCoordinationState(state);
assert.ok(typeof rendered === 'string');
assert.ok(rendered.includes('<!-- ecc-coordination:start -->'), 'Missing start marker');
assert.ok(rendered.includes('<!-- ecc-coordination:end -->'), 'Missing end marker');
assert.ok(rendered.includes('```json'), 'Missing json code fence');
})) passed += 1; else failed += 1;
if (await test('renderCoordinationState output round-trips through extractCoordinationState', () => {
const state = {
schemaVersion: 'ecc.github.coordination.v1',
kind: 'epic',
status: 'claimed',
owner: 'carol',
branch: 'feat/my-feature',
validation: 'pending',
review: 'requested',
project: { state: 'in-progress', fields: {} },
dependencies: [5, 6],
tasks: [{ title: 'Write tests', done: false }],
labels: ['coordination:claimed'],
lastAction: 'claim',
lastActionAt: '2026-01-01T00:00:00.000Z',
lastSyncAt: '2026-01-01T00:00:00.000Z',
notes: null,
};
const rendered = renderCoordinationState(state);
const extracted = extractCoordinationState(rendered);
assert.ok(extracted !== null);
assert.strictEqual(extracted.status, 'claimed');
assert.strictEqual(extracted.owner, 'carol');
assert.deepStrictEqual(extracted.dependencies, [5, 6]);
})) passed += 1; else failed += 1;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View File

@ -0,0 +1,238 @@
/**
* Tests for scripts/github-coordination.js
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const { createStateStore } = require('../../scripts/lib/state-store');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'github-coordination.js');
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function cleanup(dirPath) {
fs.rmSync(dirPath, { recursive: true, force: true });
}
function writeGhShim(rootDir, responses) {
const shimPath = path.join(rootDir, 'gh-shim.js');
const logPath = path.join(rootDir, 'gh-calls.jsonl');
fs.writeFileSync(shimPath, `
const fs = require('fs');
const responses = ${JSON.stringify(responses)};
const args = process.argv.slice(2);
const key = args.join(' ');
const logPath = process.env.ECC_GH_SHIM_LOG;
if (logPath) {
fs.appendFileSync(logPath, JSON.stringify({ args }, null, 0) + '\\n');
}
if (Object.prototype.hasOwnProperty.call(responses, key)) {
process.stdout.write(JSON.stringify(responses[key]));
process.exit(0);
}
if (args[0] === 'issue' && (args[1] === 'edit' || args[1] === 'comment')) {
process.stdout.write('{}');
process.exit(0);
}
console.error('Unexpected gh args: ' + key);
process.exit(3);
`);
return { shimPath, logPath };
}
function run(args = [], options = {}) {
return spawnSync('node', [SCRIPT, ...args], {
cwd: options.cwd || path.join(__dirname, '..', '..'),
env: {
...process.env,
...(options.env || {}),
},
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000,
});
}
function parseJson(stdout) {
return JSON.parse(stdout.trim());
}
async function test(name, fn) {
try {
await fn();
console.log(` PASS ${name}`);
return true;
} catch (error) {
console.log(` FAIL ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
async function readStore(dbPath) {
const store = await createStateStore({ dbPath });
try {
return store.listWorkItems({ limit: 20 });
} finally {
store.close();
}
}
async function runTests() {
console.log('\n=== Testing github-coordination.js ===\n');
let passed = 0;
let failed = 0;
if (await test('claims an epic issue, updates GitHub state, and caches a work item', async () => {
const rootDir = createTempDir('github-coordination-claim-');
const dbPath = path.join(rootDir, 'state.db');
try {
const epicBody = [
'# Ship GitHub-native coordination',
'',
'We want deterministic epic state.',
'',
'## Tasks',
'- [ ] Claim the epic',
'- [ ] Validate the epic',
].join('\n');
const issueView = {
number: 12,
title: 'Ship GitHub-native coordination',
body: epicBody,
url: 'https://github.com/affaan-m/ECC/issues/12',
state: 'OPEN',
labels: [{ name: 'epic' }],
author: { login: 'maintainer' },
updatedAt: '2026-06-01T12:00:00Z',
};
const shim = writeGhShim(rootDir, {
'issue view 12 --repo affaan-m/ECC --json number,title,body,url,state,labels,author,updatedAt,assignees': issueView,
});
const result = run(['claim', '12', '--repo', 'affaan-m/ECC', '--actor', 'codex', '--db', dbPath, '--json'], {
cwd: rootDir,
env: {
ECC_GH_SHIM: shim.shimPath,
ECC_GH_SHIM_LOG: shim.logPath,
},
});
assert.strictEqual(result.status, 0, result.stderr);
const payload = parseJson(result.stdout);
assert.strictEqual(payload.status, 'claimed');
assert.strictEqual(payload.owner, 'codex');
assert.strictEqual(payload.project.state, 'in-progress');
const logEntries = fs.readFileSync(shim.logPath, 'utf8').trim().split(/\r?\n/).map(line => JSON.parse(line));
assert.ok(logEntries.some(entry => entry.args[0] === 'issue' && entry.args[1] === 'edit'));
assert.ok(logEntries.some(entry => entry.args[0] === 'issue' && entry.args[1] === 'comment'));
const stored = await readStore(dbPath);
const epicItem = stored.items.find(item => item.source === 'github-epic');
assert.ok(epicItem, 'expected github epic work item');
assert.strictEqual(epicItem.status, 'in-progress');
assert.strictEqual(epicItem.metadata.coordination.status, 'claimed');
assert.strictEqual(epicItem.metadata.coordination.owner, 'codex');
} finally {
cleanup(rootDir);
}
})) passed++; else failed++;
if (await test('unblocks an epic when dependencies are closed', async () => {
const rootDir = createTempDir('github-coordination-unblock-');
const dbPath = path.join(rootDir, 'state.db');
try {
const blockedBody = [
'# Release readiness',
'',
'Dependencies: #2',
'',
'<!-- ecc-coordination:start -->',
'```json',
JSON.stringify({
schemaVersion: 'ecc.github.coordination.v1',
kind: 'epic',
status: 'blocked',
owner: 'codex',
branch: 'feat/release-readiness',
validation: 'pending',
review: 'requested',
project: { state: 'blocked', fields: {} },
dependencies: [2],
tasks: [{ title: 'Check release checklist', done: false }],
labels: ['epic', 'coordination:blocked'],
lastAction: 'claim',
lastActionAt: '2026-06-01T13:00:00Z',
lastSyncAt: '2026-06-01T13:00:00Z',
notes: null,
}, null, 2),
'```',
'<!-- ecc-coordination:end -->',
].join('\n');
const openIssue = {
number: 1,
title: 'Release readiness',
body: blockedBody,
url: 'https://github.com/affaan-m/ECC/issues/1',
state: 'OPEN',
labels: [{ name: 'epic' }, { name: 'coordination:blocked' }],
author: { login: 'codex' },
updatedAt: '2026-06-01T13:00:00Z',
};
const closedDependency = {
number: 2,
title: 'Release prerequisite',
body: '# Release prerequisite',
url: 'https://github.com/affaan-m/ECC/issues/2',
state: 'CLOSED',
labels: [{ name: 'blocked-by-release' }],
author: { login: 'maintainer' },
updatedAt: '2026-06-01T10:00:00Z',
};
const shim = writeGhShim(rootDir, {
'issue list --repo affaan-m/ECC --state all --limit 100 --json number,title,body,url,state,labels,author,updatedAt,assignees': [openIssue, closedDependency],
'issue view 1 --repo affaan-m/ECC --json number,title,body,url,state,labels,author,updatedAt,assignees': openIssue,
});
const result = run(['unblock', '--repo', 'affaan-m/ECC', '--db', dbPath, '--json'], {
cwd: rootDir,
env: {
ECC_GH_SHIM: shim.shimPath,
ECC_GH_SHIM_LOG: shim.logPath,
},
});
assert.strictEqual(result.status, 0, result.stderr);
const payload = parseJson(result.stdout);
assert.strictEqual(payload.count, 1);
assert.strictEqual(payload.items[0].status, 'ready');
const logEntries = fs.readFileSync(shim.logPath, 'utf8').trim().split(/\r?\n/).map(line => JSON.parse(line));
assert.ok(logEntries.some(entry => entry.args[0] === 'issue' && entry.args[1] === 'edit'));
assert.ok(logEntries.some(entry => entry.args[0] === 'issue' && entry.args[1] === 'comment'));
const stored = await readStore(dbPath);
const epicItem = stored.items.find(item => item.source === 'github-epic');
assert.ok(epicItem, 'expected github epic work item');
assert.strictEqual(epicItem.metadata.coordination.status, 'ready');
} finally {
cleanup(rootDir);
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests().catch(error => {
console.error(error);
process.exit(1);
});