Merge pull request #2236 from Victor-Casado/feat/github-native-coordination

feat: add github-native coordination (epic-* commands + scripts + tests). Command registry + catalog reconciled.
This commit is contained in:
Affaan Mustafa 2026-06-15 14:08:05 -04:00
commit 1c0c780452
27 changed files with 2311 additions and 24 deletions

View File

@ -11,7 +11,7 @@
{ {
"name": "ecc", "name": "ecc",
"source": "./", "source": "./",
"description": "Harness-native ECC operator layer - 67 agents, 269 skills, 85 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", "description": "Harness-native ECC operator layer - 67 agents, 269 skills, 92 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
"version": "2.0.0", "version": "2.0.0",
"author": { "author": {
"name": "Affaan Mustafa", "name": "Affaan Mustafa",

View File

@ -1,7 +1,7 @@
{ {
"name": "ecc", "name": "ecc",
"version": "2.0.0", "version": "2.0.0",
"description": "Harness-native ECC plugin for engineering teams - 67 agents, 269 skills, 85 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses", "description": "Harness-native ECC plugin for engineering teams - 67 agents, 269 skills, 92 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
"author": { "author": {
"name": "Affaan Mustafa", "name": "Affaan Mustafa",
"url": "https://x.com/affaanmustafa" "url": "https://x.com/affaanmustafa"

View File

@ -1,6 +1,6 @@
# Everything Claude Code (ECC) — Agent Instructions # Everything Claude Code (ECC) — Agent Instructions
This is a **production-ready AI coding plugin** providing 67 specialized agents, 269 skills, 85 commands, and automated hook workflows for software development. This is a **production-ready AI coding plugin** providing 67 specialized agents, 269 skills, 92 commands, and automated hook workflows for software development.
**Version:** 2.0.0 **Version:** 2.0.0
@ -153,7 +153,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
``` ```
agents/ — 67 specialized subagents agents/ — 67 specialized subagents
skills/ — 269 workflow skills and domain knowledge skills/ — 269 workflow skills and domain knowledge
commands/ — 85 slash commands commands/ — 92 slash commands
hooks/ — Trigger-based automations hooks/ — Trigger-based automations
rules/ — Always-follow guidelines (common + per-language) rules/ — Always-follow guidelines (common + per-language)
scripts/ — Cross-platform Node.js utilities scripts/ — Cross-platform Node.js utilities

View File

@ -428,7 +428,7 @@ If you stacked methods, clean up in this order:
/plugin list ecc@ecc /plugin list ecc@ecc
``` ```
**That's it!** You now have access to 67 agents, 269 skills, and 85 legacy command shims. **That's it!** You now have access to 67 agents, 269 skills, and 92 legacy command shims.
### Dashboard GUI ### Dashboard GUI
@ -1516,7 +1516,7 @@ The configuration is automatically detected from `.opencode/opencode.json`.
| Feature | Claude Code | OpenCode | Status | | Feature | Claude Code | OpenCode | Status |
|---------|---------------------|----------|--------| |---------|---------------------|----------|--------|
| Agents | PASS: 67 agents | PASS: 12 agents | **Claude Code leads** | | Agents | PASS: 67 agents | PASS: 12 agents | **Claude Code leads** |
| Commands | PASS: 85 commands | PASS: 35 commands | **Claude Code leads** | | Commands | PASS: 92 commands | PASS: 35 commands | **Claude Code leads** |
| Skills | PASS: 269 skills | PASS: 37 skills | **Claude Code leads** | | Skills | PASS: 269 skills | PASS: 37 skills | **Claude Code leads** |
| Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** | | Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** |
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** | | Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
@ -1677,7 +1677,7 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e
| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode | GitHub Copilot | | Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode | GitHub Copilot |
|---------|-----------------------|------------|-----------|----------|----------------| |---------|-----------------------|------------|-----------|----------|----------------|
| **Agents** | 67 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 | N/A | | **Agents** | 67 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 | N/A |
| **Commands** | 85 | Shared | Instruction-based | 35 | 5 prompts | | **Commands** | 92 | Shared | Instruction-based | 35 | 5 prompts |
| **Skills** | 269 | Shared | 10 (native format) | 37 | Via instructions | | **Skills** | 269 | Shared | 10 (native format) | 37 | Via instructions |
| **Hook Events** | 8 types | 15 types | None yet | 11 types | None | | **Hook Events** | 8 types | 15 types | None yet | 11 types | None |
| **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks | N/A | | **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks | N/A |

View File

@ -164,7 +164,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
/plugin list ecc@ecc /plugin list ecc@ecc
``` ```
**完成!** 你现在可以使用 67 个代理、269 个技能和 85 个命令。 **完成!** 你现在可以使用 67 个代理、269 个技能和 92 个命令。
### multi-* 命令需要额外配置 ### multi-* 命令需要额外配置

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": 85, "totalCommands": 92,
"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",
@ -941,11 +1007,11 @@
"statistics": { "statistics": {
"byType": { "byType": {
"build": 2, "build": 2,
"general": 7, "general": 10,
"orchestration": 11, "orchestration": 11,
"planning": 2, "planning": 2,
"refactoring": 1, "refactoring": 1,
"review": 9, "review": 13,
"testing": 53 "testing": 53
}, },
"topAgents": [ "topAgents": [
@ -995,6 +1061,14 @@
"skill": "continuous-learning-v2", "skill": "continuous-learning-v2",
"count": 6 "count": 6
}, },
{
"skill": "orch-add-feature",
"count": 4
},
{
"skill": "orch-change-feature",
"count": 4
},
{ {
"skill": "tdd-workflow", "skill": "tdd-workflow",
"count": 4 "count": 4
@ -1007,14 +1081,6 @@
"skill": "flutter-dart-code-review", "skill": "flutter-dart-code-review",
"count": 3 "count": 3
}, },
{
"skill": "orch-add-feature",
"count": 3
},
{
"skill": "orch-change-feature",
"count": 3
},
{ {
"skill": "orch-fix-defect", "skill": "orch-fix-defect",
"count": 3 "count": 3

View File

@ -1,6 +1,6 @@
# Everything Claude Code (ECC) — 智能体指令 # Everything Claude Code (ECC) — 智能体指令
这是一个**生产就绪的 AI 编码插件**,提供 67 个专业代理、269 项技能、85 条命令以及自动化钩子工作流,用于软件开发。 这是一个**生产就绪的 AI 编码插件**,提供 67 个专业代理、269 项技能、92 条命令以及自动化钩子工作流,用于软件开发。
**版本:** 2.0.0 **版本:** 2.0.0
@ -148,7 +148,7 @@
``` ```
agents/ — 67 个专业子代理 agents/ — 67 个专业子代理
skills/ — 269 个工作流技能和领域知识 skills/ — 269 个工作流技能和领域知识
commands/ — 85 个斜杠命令 commands/ — 92 个斜杠命令
hooks/ — 基于触发的自动化 hooks/ — 基于触发的自动化
rules/ — 始终遵循的指导方针(通用 + 每种语言) rules/ — 始终遵循的指导方针(通用 + 每种语言)
scripts/ — 跨平台 Node.js 实用工具 scripts/ — 跨平台 Node.js 实用工具

View File

@ -228,7 +228,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
/plugin list ecc@ecc /plugin list ecc@ecc
``` ```
**搞定!** 你现在可以使用 67 个智能体、269 项技能和 85 个命令了。 **搞定!** 你现在可以使用 67 个智能体、269 项技能和 92 个命令了。
*** ***
@ -1141,7 +1141,7 @@ opencode
| 功能特性 | Claude Code | OpenCode | 状态 | | 功能特性 | Claude Code | OpenCode | 状态 |
|---------|---------------|----------|--------| |---------|---------------|----------|--------|
| 智能体 | PASS: 67 个 | PASS: 12 个 | **Claude Code 领先** | | 智能体 | PASS: 67 个 | PASS: 12 个 | **Claude Code 领先** |
| 命令 | PASS: 85 个 | PASS: 35 个 | **Claude Code 领先** | | 命令 | PASS: 92 个 | PASS: 35 个 | **Claude Code 领先** |
| 技能 | PASS: 269 项 | PASS: 37 项 | **Claude Code 领先** | | 技能 | PASS: 269 项 | PASS: 37 项 | **Claude Code 领先** |
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** | | 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** | | 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
@ -1249,7 +1249,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
| 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode | | 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|---------|-----------------------|------------|-----------|----------| |---------|-----------------------|------------|-----------|----------|
| **智能体** | 67 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 | | **智能体** | 67 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
| **命令** | 85 | 共享 | 基于指令 | 35 | | **命令** | 92 | 共享 | 基于指令 | 35 |
| **技能** | 269 | 共享 | 10 (原生格式) | 37 | | **技能** | 269 | 共享 | 10 (原生格式) | 37 |
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 | | **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 | | **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |

View File

@ -0,0 +1,196 @@
#!/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;
}
// Boolean flags: map flag string → setter(parsed)
const BOOL_FLAGS = new Map([
['--help', p => { p.help = true; }],
['-h', p => { p.help = true; }],
['--json', p => { p.json = true; }],
['--dry-run', p => { p.dryRun = true; }],
]);
// Value flags: map flag string → setter(parsed, value)
const VALUE_FLAGS = new Map([
['--repo', (p, v) => { p.repo = v; }],
['--actor', (p, v) => { p.actor = v; }],
['--branch', (p, v) => { p.branch = v; }],
['--config', (p, v) => { p.configPath = v; }],
['--db', (p, v) => { p.dbPath = v; }],
['--home', (p, v) => { p.homeDir = v; }],
['--validation', (p, v) => { p.validation = v; }],
['--review', (p, v) => { p.review = v; }],
['--status', (p, v) => { p.status = v; }],
['--project-state', (p, v) => { p.projectState = v; }],
['--issue', (p, v) => { p.issueNumber = normalizeIssueNumber(v); }],
['--limit', (p, v) => { p.limit = normalizeIssueNumber(v); }],
]);
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 i = 0; i < args.length; i += 1) {
const arg = args[i];
if (BOOL_FLAGS.has(arg)) {
BOOL_FLAGS.get(arg)(parsed);
} else if (VALUE_FLAGS.has(arg)) {
VALUE_FLAGS.get(arg)(parsed, readValue(args, i, arg));
i += 1;
} else if (!arg.startsWith('-')) {
parsed.positionals.push(arg);
} else {
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;
}
function dispatchCommand(options, ctx) {
const { store, policy, rootDir } = ctx;
const base = { configPath: options.configPath, dryRun: options.dryRun };
if (options.command === 'claim') {
if (!options.issueNumber) throw new Error('Missing issue number.');
return applyClaim(options.repo, options.issueNumber, {
...base, actor: options.actor, branch: options.branch, owner: options.actor,
projectState: options.projectState, review: options.review,
status: options.status, validation: options.validation,
}, { store, policy, rootDir });
}
if (options.command === 'sync') {
return applySync(options.repo, { ...base, limit: options.limit }, { store, policy, rootDir });
}
if (options.command === 'validate') {
if (!options.issueNumber) throw new Error('Missing issue number.');
return applyValidate(options.repo, options.issueNumber, base, { store, policy, rootDir });
}
if (options.command === 'publish') {
if (!options.issueNumber) throw new Error('Missing issue number.');
return applyPublish(options.repo, options.issueNumber, base, { store, policy, rootDir });
}
if (options.command === 'review') {
if (!options.issueNumber) throw new Error('Missing issue number.');
return applyReview(options.repo, options.issueNumber, { ...base, review: options.review }, { store, policy, rootDir });
}
if (options.command === 'unblock') {
return applyUnblock(options.repo, { ...base, limit: options.limit }, { store, policy, rootDir });
}
if (options.command === 'decompose') {
if (!options.issueNumber) throw new Error('Missing issue number.');
return applyDecompose(options.repo, options.issueNumber, base, { store, policy, rootDir });
}
throw new Error(`Unknown command: ${options.command}`);
}
function formatOutput(payload, options) {
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));
}
}
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(),
});
const payload = dispatchCommand(options, { store, policy, rootDir: process.cwd() });
formatOutput(payload, options);
} 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,
};

View File

@ -0,0 +1,57 @@
'use strict';
const policy = require('./github-coordination/policy');
const parsing = require('./github-coordination/parsing');
const ghApi = require('./github-coordination/gh-api');
const state = require('./github-coordination/state');
const actions = require('./github-coordination/actions');
const store = require('./github-coordination/store');
module.exports = {
DEFAULT_CONFIG_FILE: policy.DEFAULT_CONFIG_FILE,
DEFAULT_CONFIG_PATH: policy.DEFAULT_CONFIG_PATH,
DEFAULT_POLICY: policy.DEFAULT_POLICY,
DEFAULT_SCHEMA_VERSION: policy.DEFAULT_SCHEMA_VERSION,
loadPolicy: policy.loadPolicy,
extractCoordinationState: parsing.extractCoordinationState,
extractIssueReferences: parsing.extractIssueReferences,
extractTasks: parsing.extractTasks,
mergeIssueBody: parsing.mergeIssueBody,
renderCoordinationState: parsing.renderCoordinationState,
commentIssue: ghApi.commentIssue,
editIssue: ghApi.editIssue,
getIssue: ghApi.getIssue,
listIssues: ghApi.listIssues,
normalizeIssueNumber: ghApi.normalizeIssueNumber,
normalizeLabels: ghApi.normalizeLabels,
normalizeRepo: ghApi.normalizeRepo,
runGh: ghApi.runGh,
runGhJson: ghApi.runGhJson,
buildIssueComment: state.buildIssueComment,
buildIssueStateFromAction: state.buildIssueStateFromAction,
defaultCoordinationState: state.defaultCoordinationState,
desiredLabelsForState: state.desiredLabelsForState,
getCoordinationState: state.getCoordinationState,
mapStateToWorkItemStatus: state.mapStateToWorkItemStatus,
slugifySegment: state.slugifySegment,
summarizeStateForOutput: state.summarizeStateForOutput,
syncIssueLabels: state.syncIssueLabels,
verifyDependenciesClosed: state.verifyDependenciesClosed,
applyClaim: actions.applyClaim,
applyDecompose: actions.applyDecompose,
applyPublish: actions.applyPublish,
applyReview: actions.applyReview,
applySync: actions.applySync,
applyUnblock: actions.applyUnblock,
applyValidate: actions.applyValidate,
formatCollection: actions.formatCollection,
formatSummary: actions.formatSummary,
epicWorkItemId: store.epicWorkItemId,
openStore: store.openStore,
upsertCoordinationWorkItem: store.upsertCoordinationWorkItem,
};

View File

@ -0,0 +1,385 @@
'use strict';
const { loadPolicy } = require('./policy');
const { mergeIssueBody, normalizeBodyForComparison } = require('./parsing');
const { getIssue, listIssues, editIssue, commentIssue, normalizeLabels } = require('./gh-api');
const {
assertIssueClaimable,
buildIssueComment,
buildIssueStateFromAction,
desiredLabelsForState,
getCoordinationState,
summarizeStateForOutput,
syncIssueLabels,
verifyDependenciesClosed,
} = require('./state');
const { upsertCoordinationWorkItem } = require('./store');
const { extractIssueReferences, extractTasks } = require('./parsing');
function assertValidRepo(repo) {
if (typeof repo !== 'string' || !repo.trim()) {
throw new Error(`invalid repo: expected non-empty string, got ${JSON.stringify(repo)}`);
}
}
function assertValidIssueNumber(issueNumber) {
if (!Number.isFinite(issueNumber) || issueNumber <= 0 || !Number.isInteger(issueNumber)) {
throw new Error(`invalid issueNumber: expected positive integer, got ${JSON.stringify(issueNumber)}`);
}
}
function staleCoordinationLabels(issue, nextLabels, policy) {
const epicLabel = policy.labels && policy.labels.epic;
return normalizeLabels(issue.labels).filter(l =>
(l.startsWith('coordination:') || l === epicLabel) && !nextLabels.includes(l)
);
}
// applyClaim performs a read (getIssue) → check (assertIssueClaimable) → write
// (editIssue) sequence that is NOT atomic. Two concurrent callers can both read
// an unclaimed issue, pass the check, and both succeed — resulting in a
// double-claim. A code-review finding suggested fixing this via
// context.store.acquireLock(repo, issueNumber), but that API does not exist in
// store.js; adding a call to it would throw at runtime. Left as-is until a
// locking primitive is available — callers should prevent races via external
// serialization (e.g. a serialized job queue or GitHub branch-protection rule).
function applyClaim(repo, issueNumber, options = {}, context = {}) {
assertValidRepo(repo);
assertValidIssueNumber(issueNumber);
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
const store = context.store || null;
const issue = getIssue(repo, issueNumber, options);
const currentState = getCoordinationState(issue, policy);
assertIssueClaimable(issue, currentState);
const nextState = buildIssueStateFromAction(issue, currentState, 'claim', {
owner: options.actor || options.owner || currentState.owner || issue.author?.login || null,
branch: options.branch || currentState.branch || null,
status: options.status || 'claimed',
validation: options.validation || currentState.validation || 'pending',
review: options.review || currentState.review || (policy.review.required ? 'requested' : 'not-requested'),
projectState: options.projectState || 'in-progress',
}, policy);
const trackedIssue = {
...issue,
labels: desiredLabelsForState(nextState, policy),
};
const body = mergeIssueBody(issue, nextState, policy);
if (!options.dryRun) {
editIssue(repo, issueNumber, {
body,
addLabels: trackedIssue.labels,
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
}, options);
commentIssue(repo, issueNumber, buildIssueComment('claimed', repo, issueNumber, nextState), options);
upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'claim', { ...context, policy });
}
return summarizeStateForOutput(repo, trackedIssue, nextState, 'claim', policy);
}
function applySync(repo, options = {}, context = {}) {
assertValidRepo(repo);
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
const store = context.store || null;
const issues = listIssues(repo, { ...options, state: options.state || 'all', limit: options.limit || 100 });
const syncedAt = new Date().toISOString();
const results = [];
for (const issue of issues) {
const currentState = getCoordinationState(issue, policy);
const nextState = buildIssueStateFromAction(issue, currentState, 'sync', {
status: currentState.status,
validation: currentState.validation,
review: currentState.review,
projectState: currentState.project && currentState.project.state ? currentState.project.state : 'backlog',
}, policy);
const trackedIssue = {
...issue,
labels: desiredLabelsForState(nextState, policy),
};
const body = mergeIssueBody(issue, nextState, policy);
const labelPlan = syncIssueLabels(repo, issue, nextState, policy, options);
let snapshot = null;
if (!options.dryRun) {
if (normalizeBodyForComparison(body) !== normalizeBodyForComparison(issue.body)) {
editIssue(repo, issue.number, { body }, options);
}
snapshot = upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'sync', { ...context, policy });
}
results.push({
...summarizeStateForOutput(repo, trackedIssue, nextState, 'sync', policy),
syncedAt,
labelPlan,
snapshot: snapshot || null,
});
}
return {
repo,
syncedAt,
count: results.length,
items: results,
};
}
function applyValidate(repo, issueNumber, options = {}, context = {}, existingIssue = null) {
assertValidRepo(repo);
assertValidIssueNumber(issueNumber);
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
const issue = existingIssue || getIssue(repo, issueNumber, options);
const state = getCoordinationState(issue, policy);
const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : [];
const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options);
const missingDependencies = dependencyNumbers.filter(number => !closedDependencies.includes(number));
const validations = [];
if (missingDependencies.length > 0) {
validations.push({ check: 'dependencies', ok: false, detail: missingDependencies.join(',') });
} else {
validations.push({ check: 'dependencies', ok: true, detail: 'closed' });
}
const ok = validations.every(entry => entry.ok);
const nextState = buildIssueStateFromAction(issue, state, 'validate', {
status: ok ? 'validated' : state.status,
validation: ok ? 'passed' : 'failed',
projectState: ok ? 'ready' : (state.project && state.project.state) || 'backlog',
}, policy);
const trackedIssue = {
...issue,
labels: desiredLabelsForState(nextState, policy),
};
if (!options.dryRun) {
const body = mergeIssueBody(issue, nextState, policy);
editIssue(repo, issueNumber, {
body,
addLabels: trackedIssue.labels,
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
}, options);
upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'validate', { ...context, policy });
}
return {
...summarizeStateForOutput(repo, trackedIssue, nextState, 'validate', policy),
ok,
validations,
missingDependencies,
};
}
function applyPublish(repo, issueNumber, options = {}, context = {}) {
assertValidRepo(repo);
assertValidIssueNumber(issueNumber);
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
const issue = getIssue(repo, issueNumber, options);
const state = getCoordinationState(issue, policy);
const validation = applyValidate(repo, issueNumber, { ...options, dryRun: true }, context, issue);
if (!validation.ok) {
throw new Error(`Issue #${issueNumber} is not ready to publish: ${validation.validations.map(entry => `${entry.check}=${entry.ok}`).join(', ')}`);
}
if (policy.review && policy.review.required && state.review !== 'approved') {
throw new Error(`Issue #${issueNumber} cannot be published: review approval required (current: ${state.review})`);
}
const nextState = buildIssueStateFromAction(issue, state, 'publish', {
status: 'published',
validation: 'passed',
review: state.review === 'changes-requested' ? state.review : 'approved',
projectState: 'done',
}, policy);
const trackedIssue = {
...issue,
labels: desiredLabelsForState(nextState, policy),
};
if (!options.dryRun) {
const body = mergeIssueBody(issue, nextState, policy);
editIssue(repo, issueNumber, {
body,
addLabels: trackedIssue.labels,
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
}, options);
commentIssue(repo, issueNumber, buildIssueComment('published', repo, issueNumber, nextState, {
validation: 'passed',
}), options);
upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'publish', { ...context, policy });
}
return summarizeStateForOutput(repo, trackedIssue, nextState, 'publish', policy);
}
function applyReview(repo, issueNumber, options = {}, context = {}) {
assertValidRepo(repo);
assertValidIssueNumber(issueNumber);
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
const issue = getIssue(repo, issueNumber, options);
const state = getCoordinationState(issue, policy);
const reviewState = options.review || 'approved';
const nextState = buildIssueStateFromAction(issue, state, 'review', {
status: reviewState === 'approved' ? 'ready' : reviewState === 'requested' ? 'claimed' : 'blocked',
review: reviewState,
projectState: reviewState === 'approved' ? 'ready' : 'blocked',
}, policy);
const trackedIssue = {
...issue,
labels: desiredLabelsForState(nextState, policy),
};
if (!options.dryRun) {
const body = mergeIssueBody(issue, nextState, policy);
editIssue(repo, issueNumber, {
body,
addLabels: trackedIssue.labels,
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
}, options);
commentIssue(repo, issueNumber, buildIssueComment('reviewed', repo, issueNumber, nextState, {
review: reviewState,
}), options);
upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'review', { ...context, policy });
}
return summarizeStateForOutput(repo, trackedIssue, nextState, 'review', policy);
}
function applyDecompose(repo, issueNumber, options = {}, context = {}) {
assertValidRepo(repo);
assertValidIssueNumber(issueNumber);
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
const issue = getIssue(repo, issueNumber, options);
const state = getCoordinationState(issue, policy);
const tasks = extractTasks(issue.body);
const dependencies = extractIssueReferences(issue.body);
const nextState = buildIssueStateFromAction(issue, state, 'decompose', {
tasks,
dependencies,
status: tasks.some(task => !task.done) ? 'claimed' : state.status,
projectState: tasks.some(task => !task.done) ? 'in-progress' : (state.project && state.project.state) || 'backlog',
}, policy);
const trackedIssue = {
...issue,
labels: desiredLabelsForState(nextState, policy),
};
if (!options.dryRun) {
const body = mergeIssueBody(issue, nextState, policy);
editIssue(repo, issueNumber, {
body,
addLabels: trackedIssue.labels,
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
}, options);
commentIssue(repo, issueNumber, buildIssueComment('decomposed', repo, issueNumber, nextState, {
taskCount: String(tasks.length),
dependencyCount: String(dependencies.length),
}), options);
upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'decompose', { ...context, policy });
}
return {
...summarizeStateForOutput(repo, trackedIssue, nextState, 'decompose', policy),
tasks,
dependencyCount: dependencies.length,
};
}
function applyUnblock(repo, options = {}, context = {}) {
assertValidRepo(repo);
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
const store = context.store || null;
const issues = listIssues(repo, { ...options, state: 'all', limit: options.limit || 100 });
const results = [];
for (const issue of issues) {
const state = getCoordinationState(issue, policy);
if (state.status !== 'blocked') {
continue;
}
const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : [];
const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options, issues);
if (dependencyNumbers.length > 0 && closedDependencies.length !== dependencyNumbers.length) {
continue;
}
const nextState = buildIssueStateFromAction(issue, state, 'unblock', {
status: 'ready',
projectState: 'ready',
validation: state.validation === 'failed' ? 'pending' : state.validation,
}, policy);
const trackedIssue = {
...issue,
labels: desiredLabelsForState(nextState, policy),
};
if (!options.dryRun) {
const body = mergeIssueBody(issue, nextState, policy);
editIssue(repo, issue.number, {
body,
addLabels: trackedIssue.labels,
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
}, options);
commentIssue(repo, issue.number, buildIssueComment('unblocked', repo, issue.number, nextState, {
dependencies: dependencyNumbers.length > 0 ? dependencyNumbers.join(',') : 'none',
}), options);
upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'unblock', { ...context, policy });
}
results.push(summarizeStateForOutput(repo, trackedIssue, nextState, 'unblock', policy));
}
return {
repo,
count: results.length,
items: results,
};
}
function formatSummary(payload) {
const lines = [
`${payload.action || 'sync'} epic #${payload.issueNumber}: ${payload.issueTitle}`,
`Repo: ${payload.repo}`,
`Status: ${payload.status}`,
`Owner: ${payload.owner || '(unassigned)'}`,
`Branch: ${payload.branch || '(none)'}`,
`Validation: ${payload.validation || 'pending'}`,
`Review: ${payload.review || 'not-requested'}`,
];
if (payload.tasks && payload.tasks.length > 0) {
lines.push(`Tasks: ${payload.tasks.length}`);
}
if (payload.dependencies && payload.dependencies.length > 0) {
lines.push(`Dependencies: ${payload.dependencies.join(', ')}`);
}
return `${lines.join('\n')}\n`;
}
function formatCollection(payload) {
const lines = [
`Repo: ${payload.repo}`,
`Items: ${payload.count}`,
];
for (const item of payload.items || []) {
lines.push(`- #${item.issueNumber} ${item.status}: ${item.issueTitle}`);
}
return `${lines.join('\n')}\n`;
}
module.exports = {
applyClaim,
applyDecompose,
applyPublish,
applyReview,
applySync,
applyUnblock,
applyValidate,
formatCollection,
formatSummary,
};

View File

@ -0,0 +1,175 @@
'use strict';
const { spawnSync } = require('child_process');
function normalizeRepo(repo) {
const parts = String(repo || '').split('/').filter(Boolean);
if (parts.length !== 2) {
throw new Error(`Invalid repo format: "${repo}". Expected "owner/repo".`);
}
const [owner, name] = parts;
return { owner, name };
}
function normalizeIssueNumber(value) {
const parsed = Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Invalid issue number: ${value}`);
}
return parsed;
}
function normalizeLabelValue(label) {
if (typeof label === 'string') {
return label.trim();
}
if (label && typeof label === 'object') {
return String(label.name || label.label || '').trim();
}
return '';
}
function normalizeLabels(labels) {
return Array.from(new Set((Array.isArray(labels) ? labels : []).map(normalizeLabelValue).filter(Boolean))).sort();
}
function runCommand(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
env: options.env || process.env,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
});
if (result.error) {
throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
}
return result.stdout || '';
}
// ECC_GH_SHIM creates a trust boundary: when set, shimPath replaces the real
// `gh` binary and command/commandArgs execute an arbitrary script via
// process.execPath. This variable MUST only be set in trusted, isolated test
// environments (e.g., a test's own temp directory). Never set ECC_GH_SHIM in
// production — doing so allows arbitrary script execution under the caller's
// privileges.
function runGh(args, options = {}) {
const shimPath = process.env.ECC_GH_SHIM;
const command = shimPath ? process.execPath : 'gh';
const commandArgs = shimPath ? [shimPath, ...args] : args;
const env = { ...process.env };
if (options.stripGithubToken) {
delete env.GITHUB_TOKEN;
}
return runCommand(command, commandArgs, { cwd: options.cwd, env });
}
function runGhJson(args, options = {}) {
try {
return JSON.parse(runGh(args, options) || 'null');
} catch (error) {
throw new Error(`gh ${args.join(' ')} returned invalid JSON: ${error.message}`);
}
}
function getIssue(repo, issueNumber, options = {}) {
const { owner, name } = normalizeRepo(repo);
const json = runGhJson([
'issue',
'view',
String(issueNumber),
'--repo',
`${owner}/${name}`,
'--json',
'number,title,body,url,state,labels,author,updatedAt,assignees',
], options);
if (!json) {
throw new Error(`Unable to load issue #${issueNumber} from ${repo}`);
}
return json;
}
function listIssues(repo, options = {}) {
const { owner, name } = normalizeRepo(repo);
const limit = Number.isFinite(options.limit) ? options.limit : 100;
const state = options.state || 'all';
return runGhJson([
'issue',
'list',
'--repo',
`${owner}/${name}`,
'--state',
state,
'--limit',
String(limit),
'--json',
'number,title,body,url,state,labels,author,updatedAt,assignees',
], options) || [];
}
function editIssue(repo, issueNumber, options = {}) {
const { owner, name } = normalizeRepo(repo);
const args = [
'issue',
'edit',
String(issueNumber),
'--repo',
`${owner}/${name}`,
];
if (options.body !== undefined) {
args.push('--body', options.body);
}
for (const label of options.addLabels || []) {
args.push('--add-label', label);
}
for (const label of options.removeLabels || []) {
args.push('--remove-label', label);
}
if (options.title) {
args.push('--title', options.title);
}
if (options.assignee) {
args.push('--add-assignee', options.assignee);
}
return runGh(args, options);
}
function commentIssue(repo, issueNumber, body, options = {}) {
const { owner, name } = normalizeRepo(repo);
return runGh([
'issue',
'comment',
String(issueNumber),
'--repo',
`${owner}/${name}`,
'--body',
body,
], options);
}
module.exports = {
commentIssue,
editIssue,
getIssue,
listIssues,
normalizeIssueNumber,
normalizeLabels,
normalizeRepo,
runGh,
runGhJson,
};

View File

@ -0,0 +1,143 @@
'use strict';
const { DEFAULT_POLICY, DEFAULT_SCHEMA_VERSION, DEFAULT_SECTION_MARKER } = require('./policy');
function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function normalizeBodyForComparison(body) {
return (body || '').replace(/"lastSyncAt"\s*:\s*[^,\}\n]+/g, '"lastSyncAt": NORMALIZED');
}
function extractCoordinationState(body, policy = DEFAULT_POLICY) {
const marker = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER);
const regex = new RegExp(
`<!--\\s*${marker}:start\\s*-->\\s*` +
'```json\\s*([\\s\\S]*?)\\s*```' +
`\\s*<!--\\s*${marker}:end\\s*-->`,
'm'
);
const match = String(body || '').match(regex);
if (!match) {
return null;
}
try {
const parsed = JSON.parse(match[1]);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (error) {
throw new SyntaxError(
`Malformed coordination JSON in body: ${error.message} — raw: ${match[1].slice(0, 120)}`
);
}
}
function extractIssueReferences(text) {
const refs = new Set();
const source = String(text || '');
for (const match of source.matchAll(/(?:^|[^\d])#(\d+)\b/g)) {
refs.add(Number.parseInt(match[1], 10));
}
return Array.from(refs).filter(Number.isFinite).sort((a, b) => a - b);
}
function extractTasks(body) {
const lines = String(body || '').split(/\r?\n/);
const tasks = [];
let inTasks = false;
for (const rawLine of lines) {
const line = rawLine.trim();
if (/^#{2,3}\s+tasks\b/i.test(line) || /^#{2,3}\s+task list\b/i.test(line)) {
inTasks = true;
continue;
}
if (inTasks && /^#{2,3}\s+\S/.test(line)) {
break;
}
if (inTasks) {
const taskMatch = line.match(/^- \[( |x)\]\s+(.+)$/i);
if (taskMatch) {
tasks.push({
title: taskMatch[2].trim(),
done: taskMatch[1].toLowerCase() === 'x',
});
}
}
}
return tasks;
}
function parseStringList(value) {
if (!value) {
return [];
}
return String(value)
.split(',')
.map(part => part.trim())
.filter(Boolean);
}
function renderCoordinationState(state, policy = DEFAULT_POLICY) {
const marker = policy.sectionMarker || DEFAULT_SECTION_MARKER;
const payload = {
schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION,
kind: state.kind || 'epic',
status: state.status || 'available',
owner: state.owner || null,
branch: state.branch || null,
validation: state.validation || 'pending',
review: state.review || 'not-requested',
project: state.project || { state: 'backlog', fields: {} },
dependencies: Array.isArray(state.dependencies) ? state.dependencies : [],
tasks: Array.isArray(state.tasks) ? state.tasks : [],
labels: Array.isArray(state.labels) ? state.labels : [],
lastAction: state.lastAction || 'sync',
lastActionAt: state.lastActionAt || new Date().toISOString(),
lastSyncAt: state.lastSyncAt || new Date().toISOString(),
notes: state.notes || null,
};
return [
`<!-- ${marker}:start -->`,
'```json',
JSON.stringify(payload, null, 2),
'```',
`<!-- ${marker}:end -->`,
].join('\n');
}
function mergeIssueBody(issue, nextState, policy = DEFAULT_POLICY) {
const body = String(issue.body || '');
const markerEscaped = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER);
const rendered = renderCoordinationState(nextState, policy);
const regex = new RegExp(
`\\n?<!--\\s*${markerEscaped}:start\\s*-->[\\s\\S]*?<!--\\s*${markerEscaped}:end\\s*-->\\n?`,
'm'
);
if (regex.test(body)) {
return body.replace(regex, `\n${rendered}\n`).trim() + '\n';
}
const trimmed = body.trimEnd();
if (!trimmed) {
return `${rendered}\n`;
}
return `${trimmed}\n\n${rendered}\n`;
}
module.exports = {
escapeRegExp,
extractCoordinationState,
extractIssueReferences,
extractTasks,
mergeIssueBody,
normalizeBodyForComparison,
parseStringList,
renderCoordinationState,
};

View File

@ -0,0 +1,101 @@
'use strict';
const fs = require('fs');
const path = require('path');
const DEFAULT_CONFIG_FILE = 'github-native-coordination.json';
const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', '..', '..', 'config', DEFAULT_CONFIG_FILE);
const DEFAULT_SECTION_MARKER = 'ecc-coordination';
const DEFAULT_SCHEMA_VERSION = 'ecc.github.coordination.v1';
const DEFAULT_LABELS = Object.freeze({
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',
});
const DEFAULT_POLICY = Object.freeze({
schemaVersion: DEFAULT_SCHEMA_VERSION,
sectionMarker: DEFAULT_SECTION_MARKER,
labels: DEFAULT_LABELS,
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',
},
},
});
function loadPolicy(rootDir = process.cwd(), configPath = null) {
const resolvedPath = configPath
? path.resolve(configPath)
: path.join(rootDir, 'config', DEFAULT_CONFIG_FILE);
if (!fs.existsSync(resolvedPath)) {
return {
...DEFAULT_POLICY,
sourcePath: null,
};
}
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
} catch (error) {
throw new Error(`Failed to load policy from ${resolvedPath}: ${error.message}`);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`Policy file ${resolvedPath} must contain a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`);
}
const labels = typeof parsed.labels === 'object' && parsed.labels !== null && !Array.isArray(parsed.labels) ? parsed.labels : {};
const review = typeof parsed.review === 'object' && parsed.review !== null && !Array.isArray(parsed.review) ? parsed.review : {};
const validation = typeof parsed.validation === 'object' && parsed.validation !== null && !Array.isArray(parsed.validation) ? parsed.validation : {};
const branchModel = typeof parsed.branchModel === 'object' && parsed.branchModel !== null && !Array.isArray(parsed.branchModel) ? parsed.branchModel : {};
const project = typeof parsed.project === 'object' && parsed.project !== null && !Array.isArray(parsed.project) ? parsed.project : {};
const fieldNames = typeof project.fieldNames === 'object' && project.fieldNames !== null && !Array.isArray(project.fieldNames) ? project.fieldNames : {};
return {
...DEFAULT_POLICY,
...parsed,
labels: { ...DEFAULT_LABELS, ...labels },
review: { ...DEFAULT_POLICY.review, ...review },
validation: { ...DEFAULT_POLICY.validation, ...validation },
branchModel: { ...DEFAULT_POLICY.branchModel, ...branchModel },
project: {
...DEFAULT_POLICY.project,
...project,
fieldNames: { ...DEFAULT_POLICY.project.fieldNames, ...fieldNames },
},
sourcePath: resolvedPath,
};
}
module.exports = {
DEFAULT_CONFIG_FILE,
DEFAULT_CONFIG_PATH,
DEFAULT_LABELS,
DEFAULT_POLICY,
DEFAULT_SCHEMA_VERSION,
DEFAULT_SECTION_MARKER,
loadPolicy,
};

View File

@ -0,0 +1,252 @@
'use strict';
const { DEFAULT_POLICY, DEFAULT_SCHEMA_VERSION } = require('./policy');
const { extractIssueReferences, extractTasks } = require('./parsing');
const { normalizeLabels, listIssues, editIssue } = require('./gh-api');
function slugifySegment(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'unknown';
}
function defaultCoordinationState(issue, policy = DEFAULT_POLICY) {
return {
schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION,
kind: 'epic',
status: 'available',
owner: issue && issue.author && issue.author.login ? issue.author.login : null,
branch: null,
validation: 'pending',
review: 'not-requested',
project: {
state: 'backlog',
fields: {},
},
dependencies: extractIssueReferences(issue && issue.body ? issue.body : ''),
tasks: extractTasks(issue && issue.body ? issue.body : ''),
labels: normalizeLabels(issue && issue.labels),
lastAction: 'sync',
lastActionAt: new Date().toISOString(),
lastSyncAt: new Date().toISOString(),
notes: null,
};
}
function getCoordinationState(issue, policy = DEFAULT_POLICY) {
const { extractCoordinationState } = require('./parsing'); // lazy to avoid circular init order
let existing;
try {
existing = extractCoordinationState(issue && issue.body, policy);
} catch (error) {
process.stderr.write(`[github-coordination] Warning: ${error.message} (issue #${issue && issue.number})\n`);
existing = null;
}
if (existing) {
return {
...defaultCoordinationState(issue, policy),
...existing,
project: {
...defaultCoordinationState(issue, policy).project,
...(existing.project || {}),
},
tasks: Array.isArray(existing.tasks) ? existing.tasks : extractTasks(issue && issue.body ? issue.body : ''),
dependencies: Array.isArray(existing.dependencies) ? existing.dependencies : extractIssueReferences(issue && issue.body ? issue.body : ''),
labels: Array.isArray(existing.labels) ? existing.labels : normalizeLabels(issue && issue.labels),
};
}
return defaultCoordinationState(issue, policy);
}
function buildIssueStateFromAction(issue, currentState, action, options = {}, policy = DEFAULT_POLICY) {
const now = new Date().toISOString();
const next = {
...currentState,
schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION,
kind: 'epic',
lastAction: action,
lastActionAt: now,
lastSyncAt: now,
labels: normalizeLabels(issue.labels),
dependencies: Array.isArray(currentState.dependencies) ? currentState.dependencies : extractIssueReferences(issue.body),
tasks: Array.isArray(currentState.tasks) ? currentState.tasks : extractTasks(issue.body),
};
if (options.owner !== undefined) next.owner = options.owner;
if (options.branch !== undefined) next.branch = options.branch;
if (options.validation !== undefined) next.validation = options.validation;
if (options.review !== undefined) next.review = options.review;
if (options.status !== undefined) next.status = options.status;
if (options.projectState !== undefined) {
next.project = { ...(next.project || {}), state: options.projectState };
}
if (options.notes !== undefined) next.notes = options.notes;
if (options.tasks !== undefined) next.tasks = options.tasks;
if (options.dependencies !== undefined) next.dependencies = options.dependencies;
return next;
}
function desiredLabelsForState(state, policy = DEFAULT_POLICY) {
const labels = [];
const known = policy.labels || DEFAULT_POLICY.labels;
labels.push(known.epic);
labels.push(known.synced);
if (state.status === 'available') labels.push(known.available);
if (state.status === 'claimed') labels.push(known.claimed);
if (state.status === 'ready') labels.push(known.ready);
if (state.status === 'blocked') labels.push(known.blocked);
if (state.validation === 'passed') labels.push(known.validated);
if (state.review === 'requested') labels.push(known.reviewRequested);
if (state.review === 'approved') labels.push(known.reviewApproved);
if (state.review === 'changes-requested') labels.push(known.reviewChangesRequested);
if (state.status === 'published') labels.push(known.published);
return Array.from(new Set(labels.filter(Boolean))).sort();
}
function syncIssueLabels(repo, issue, state, policy = DEFAULT_POLICY, options = {}) {
const desired = new Set(desiredLabelsForState(state, policy));
const current = new Set(normalizeLabels(issue.labels));
const addLabels = Array.from(desired).filter(label => !current.has(label));
const removeLabels = Array.from(current).filter(label => {
if (!label.startsWith('coordination:') && label !== (policy.labels && policy.labels.epic)) {
return false;
}
return !desired.has(label);
});
if (options.dryRun || (addLabels.length === 0 && removeLabels.length === 0)) {
return { addLabels, removeLabels };
}
if (addLabels.length > 0 || removeLabels.length > 0) {
editIssue(repo, issue.number, { ...options, addLabels, removeLabels });
}
return { addLabels, removeLabels };
}
function findIssueByNumber(issues, issueNumber) {
return issues.find(issue => Number(issue.number) === Number(issueNumber)) || null;
}
function buildIssueComment(action, repo, issueNumber, state, extra = {}) {
const summary = [
`ECC coordination ${action}`,
`Repo: ${repo}`,
`Issue: #${issueNumber}`,
`Status: ${state.status}`,
`Owner: ${state.owner || '(unassigned)'}`,
`Branch: ${state.branch || '(none)'}`,
`Validation: ${state.validation || 'pending'}`,
`Review: ${state.review || 'not-requested'}`,
];
for (const [key, value] of Object.entries(extra)) {
summary.push(`${key}: ${value}`);
}
summary.push('', 'This comment is part of the append-only coordination audit trail.');
return summary.join('\n');
}
function mapStateToWorkItemStatus(state) {
switch (state) {
case 'blocked':
return 'blocked';
case 'published':
return 'done';
case 'validated':
case 'reviewing':
case 'claimed':
case 'ready':
return 'in-progress';
case 'changes-requested':
return 'needs-review';
case 'available':
default:
return 'open';
}
}
function summarizeProjectProjection(state, policy = DEFAULT_POLICY) {
return {
enabled: Boolean(policy.project && policy.project.enabled),
state: state.project && state.project.state ? state.project.state : 'backlog',
fields: {
...(state.project && state.project.fields ? state.project.fields : {}),
},
};
}
function summarizeStateForOutput(repo, issue, state, action, policy = DEFAULT_POLICY) {
return {
schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION,
repo,
issueNumber: issue.number,
issueUrl: issue.url || null,
issueTitle: issue.title,
action,
status: state.status,
owner: state.owner || null,
branch: state.branch || null,
validation: state.validation || 'pending',
review: state.review || 'not-requested',
project: summarizeProjectProjection(state, policy),
dependencies: Array.isArray(state.dependencies) ? state.dependencies : [],
tasks: Array.isArray(state.tasks) ? state.tasks : [],
labels: normalizeLabels(issue.labels),
workItemId: `github-${slugifySegment(repo)}-epic-${issue.number}`,
lastActionAt: state.lastActionAt || null,
lastSyncAt: state.lastSyncAt || null,
};
}
function assertIssueClaimable(issue, state) {
if (String(issue.state || '').toLowerCase() !== 'open') {
throw new Error(`Issue #${issue.number} is not open`);
}
if (state.status === 'claimed') {
throw new Error(`Issue #${issue.number} is already claimed by ${state.owner || 'unknown'}`);
}
}
function verifyDependenciesClosed(repo, dependencyNumbers, options = {}, allIssues = null) {
if (!Array.isArray(dependencyNumbers) || dependencyNumbers.length === 0) {
return [];
}
const issueList = allIssues || listIssues(repo, { ...options, state: 'all', limit: options.limit || 200 });
const closed = [];
for (const dependencyNumber of dependencyNumbers) {
const issue = findIssueByNumber(issueList, dependencyNumber);
if (!issue) {
process.stderr.write(`[github-coordination] Warning: dependency issue #${dependencyNumber} not found in issue list (may be in a different repo or beyond limit)\n`);
} else if (String(issue.state || '').toLowerCase() === 'closed') {
closed.push(dependencyNumber);
}
}
return closed;
}
module.exports = {
assertIssueClaimable,
buildIssueComment,
buildIssueStateFromAction,
defaultCoordinationState,
desiredLabelsForState,
findIssueByNumber,
getCoordinationState,
mapStateToWorkItemStatus,
slugifySegment,
summarizeProjectProjection,
summarizeStateForOutput,
syncIssueLabels,
verifyDependenciesClosed,
};

View File

@ -0,0 +1,65 @@
'use strict';
const os = require('os');
const { createStateStore } = require('../state-store');
const { DEFAULT_SCHEMA_VERSION, DEFAULT_POLICY } = require('./policy');
const { normalizeLabels } = require('./gh-api');
const { slugifySegment, mapStateToWorkItemStatus, summarizeProjectProjection } = require('./state');
function epicWorkItemId(repo, issueNumber) {
return `github-${slugifySegment(repo)}-epic-${issueNumber}`;
}
function upsertCoordinationWorkItem(store, repo, issue, state, action, options = {}) {
if (!store) {
return null;
}
const now = new Date().toISOString();
const metadata = {
schemaVersion: state.schemaVersion || DEFAULT_SCHEMA_VERSION,
repo,
issueNumber: issue.number,
issueUrl: issue.url || null,
issueTitle: issue.title || null,
labels: normalizeLabels(issue.labels),
coordination: state,
projectProjection: summarizeProjectProjection(state, options.policy || DEFAULT_POLICY),
action,
actionAt: now,
syncedBy: 'ecc-github-coordination',
};
return store.upsertWorkItem({
id: epicWorkItemId(repo, issue.number),
source: 'github-epic',
sourceId: String(issue.number),
title: `Epic #${issue.number}: ${issue.title}`,
status: mapStateToWorkItemStatus(state.status),
priority: state.status === 'blocked' ? 'high' : 'normal',
url: issue.url || null,
owner: state.owner || (issue.author && issue.author.login) || null,
repoRoot: options.repoRoot || process.cwd(),
sessionId: options.sessionId || null,
metadata,
updatedAt: now,
});
}
async function openStore(options = {}) {
if (options.dbPath === false) {
return null;
}
return createStateStore({
dbPath: options.dbPath,
homeDir: options.homeDir || process.env.HOME || os.homedir(),
});
}
module.exports = {
epicWorkItemId,
openStore,
upsertCoordinationWorkItem,
};

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,307 @@
'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 runGroup(group, descriptors, counters) {
for (const { name, fn } of descriptors.filter(d => d.group === group)) {
if (await test(name, fn)) counters.passed += 1;
else counters.failed += 1;
}
}
const DESCRIPTORS = [
// normalizeRepo
{
group: 'normalizeRepo',
name: 'normalizeRepo returns { owner, name } for "owner/repo"',
fn: () => {
const result = normalizeRepo('acme/my-repo');
assert.deepStrictEqual(result, { owner: 'acme', name: 'my-repo' });
},
},
{
group: 'normalizeRepo',
name: 'normalizeRepo throws on "owner/repo/extra"',
fn: () => {
assert.throws(() => normalizeRepo('owner/repo/extra'), /Invalid repo format/);
},
},
{
group: 'normalizeRepo',
name: 'normalizeRepo throws on bare string with no slash',
fn: () => {
assert.throws(() => normalizeRepo('justowner'), /Invalid repo format/);
},
},
{
group: 'normalizeRepo',
name: 'normalizeRepo throws on empty string',
fn: () => {
assert.throws(() => normalizeRepo(''), /Invalid repo format/);
},
},
{
group: 'normalizeRepo',
name: 'normalizeRepo throws on whitespace-only string',
fn: () => {
assert.throws(() => normalizeRepo(' '), /Invalid repo format/);
},
},
// extractCoordinationState
{
group: 'extractCoordinationState',
name: 'extractCoordinationState returns null for body with no coordination section',
fn: () => {
const result = extractCoordinationState('## Some issue\n\nJust text, no coordination block.');
assert.strictEqual(result, null);
},
},
{
group: 'extractCoordinationState',
name: 'extractCoordinationState returns parsed state from a proper coordination JSON block',
fn: () => {
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');
},
},
{
group: 'extractCoordinationState',
name: 'extractCoordinationState throws SyntaxError when JSON block is malformed',
fn: () => {
const body = [
'<!-- ecc-coordination:start -->',
'```json',
'{ not valid json }',
'```',
'<!-- ecc-coordination:end -->',
].join('\n');
assert.throws(() => extractCoordinationState(body), SyntaxError);
},
},
// buildIssueStateFromAction
{
group: 'buildIssueStateFromAction',
name: 'buildIssueStateFromAction with "claim" action sets status, owner, branch, lastAction, lastActionAt',
fn: () => {
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);
},
},
{
group: 'buildIssueStateFromAction',
name: 'buildIssueStateFromAction with "unblock" action preserves owner from existing state',
fn: () => {
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');
},
},
{
group: 'buildIssueStateFromAction',
name: 'buildIssueStateFromAction with "validate" action sets status "validated" and validation "passed"',
fn: () => {
const issue = { number: 3, body: '', labels: [] };
const currentState = {
schemaVersion: DEFAULT_POLICY.schemaVersion,
status: 'claimed', owner: 'carol', branch: 'feat/new',
validation: 'pending', review: 'not-requested', dependencies: [], tasks: [],
};
const result = buildIssueStateFromAction(issue, currentState, 'validate', {
status: 'validated', validation: 'passed',
});
assert.strictEqual(result.status, 'validated');
assert.strictEqual(result.validation, 'passed');
assert.strictEqual(result.lastAction, 'validate');
assert.strictEqual(result.owner, 'carol');
},
},
// desiredLabelsForState
{
group: 'desiredLabelsForState',
name: 'desiredLabelsForState for status "available" includes "coordination:available"',
fn: () => {
const labels = desiredLabelsForState({ status: 'available' });
assert.ok(Array.isArray(labels));
assert.ok(labels.includes('coordination:available'), `Expected coordination:available in [${labels.join(', ')}]`);
},
},
{
group: 'desiredLabelsForState',
name: 'desiredLabelsForState for status "claimed" includes "coordination:claimed" but not "coordination:available"',
fn: () => {
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(', ')}]`);
},
},
{
group: 'desiredLabelsForState',
name: 'desiredLabelsForState for status "blocked" includes "coordination:blocked"',
fn: () => {
const labels = desiredLabelsForState({ status: 'blocked' });
assert.ok(labels.includes('coordination:blocked'), `Expected coordination:blocked in [${labels.join(', ')}]`);
assert.ok(!labels.includes('coordination:available'), `Did not expect coordination:available in [${labels.join(', ')}]`);
},
},
{
group: 'desiredLabelsForState',
name: 'desiredLabelsForState for status "ready" includes "coordination:ready"',
fn: () => {
const labels = desiredLabelsForState({ status: 'ready' });
assert.ok(labels.includes('coordination:ready'), `Expected coordination:ready in [${labels.join(', ')}]`);
},
},
// extractTasks
{
group: 'extractTasks',
name: 'extractTasks returns empty array when body has no Tasks section',
fn: () => {
const tasks = extractTasks('Some issue without any task list.');
assert.deepStrictEqual(tasks, []);
},
},
{
group: 'extractTasks',
name: 'extractTasks parses completed and open checkboxes under ## Tasks heading',
fn: () => {
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');
},
},
{
group: 'extractTasks',
name: 'extractTasks stops parsing at next heading after task section',
fn: () => {
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');
},
},
// renderCoordinationState
{
group: 'renderCoordinationState',
name: 'renderCoordinationState returns a string containing the section marker',
fn: () => {
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');
},
},
{
group: 'renderCoordinationState',
name: 'renderCoordinationState output round-trips through extractCoordinationState',
fn: () => {
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]);
},
},
];
async function runTests() {
console.log('\n=== Testing github-coordination ===\n');
const counters = { passed: 0, failed: 0 };
const groups = [...new Set(DESCRIPTORS.map(d => d.group))];
for (const group of groups) {
await runGroup(group, DESCRIPTORS, counters);
}
console.log(`\nResults: Passed: ${counters.passed}, Failed: ${counters.failed}`);
process.exit(counters.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();
process.stdout.write(` PASS ${name}\n`);
return true;
} catch (error) {
process.stdout.write(` FAIL ${name}\n`);
process.stdout.write(` Error: ${error.message}\n`);
return false;
}
}
async function readStore(dbPath) {
const store = await createStateStore({ dbPath });
try {
return store.listWorkItems({ limit: 20 });
} finally {
store.close();
}
}
async function runTests() {
process.stdout.write('\n=== Testing github-coordination.js ===\n\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++;
process.stdout.write(`\nResults: Passed: ${passed}, Failed: ${failed}\n`);
process.exit(failed > 0 ? 1 : 0);
}
runTests().catch(error => {
console.error(error);
process.exit(1);
});