mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-10 18:23:12 +08:00
feat: add dynamic workflow team orchestration surface
Adds dynamic workflow/team orchestration skills, the content pack, and control-pane work-item/Kanban state DB support. Includes reviewer hardening for state-db CLI validation, optional state DB failure handling, and mergeStateStatus projection.
This commit is contained in:
@@ -34,6 +34,7 @@ async function main(argv = process.argv) {
|
||||
|
||||
console.log(`ECC Control Pane: ${app.url}`);
|
||||
console.log(`ECC2 database: ${app.config.dbPath}`);
|
||||
console.log(`ECC state database: ${app.config.stateDbPath}`);
|
||||
console.log(args.allowActions ? 'Actions: enabled for local allowlist' : 'Actions: read-only');
|
||||
|
||||
if (args.openBrowser) {
|
||||
|
||||
@@ -12,9 +12,10 @@ const { renderControlPaneHtml } = require('./ui');
|
||||
function usage() {
|
||||
return [
|
||||
'Usage:',
|
||||
' node scripts/control-pane.js [--host 127.0.0.1] [--port 8765] [--db <ecc2.db>] [--config <ecc2.toml>] [--query <text>]',
|
||||
' node scripts/control-pane.js [--host 127.0.0.1] [--port 8765] [--db <ecc2.db>] [--state-db <state.db>] [--config <ecc2.toml>] [--query <text>]',
|
||||
'',
|
||||
'Options:',
|
||||
' --state-db <path> Read agent work items from an ECC state-store database',
|
||||
' --read-only Disable action execution endpoints',
|
||||
' --no-open Do not open a browser after the server starts',
|
||||
' --help Show this help',
|
||||
@@ -26,6 +27,15 @@ function valueAfter(args, name) {
|
||||
return index >= 0 ? args[index + 1] : null;
|
||||
}
|
||||
|
||||
function pathValueAfter(args, name) {
|
||||
const value = valueAfter(args, name);
|
||||
if (value === null) return null;
|
||||
if (!value || value.startsWith('-')) {
|
||||
throw new Error(`Invalid ${name} value: expected a path`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const help = args.includes('--help') || args.includes('-h');
|
||||
@@ -41,6 +51,7 @@ function parseArgs(argv) {
|
||||
host,
|
||||
port,
|
||||
dbPath: valueAfter(args, '--db'),
|
||||
stateDbPath: pathValueAfter(args, '--state-db'),
|
||||
configPath: valueAfter(args, '--config'),
|
||||
query: valueAfter(args, '--query') || '',
|
||||
openBrowser: !args.includes('--no-open'),
|
||||
@@ -144,6 +155,7 @@ function createControlPaneServer(options = {}) {
|
||||
cwd: options.cwd || repoRoot,
|
||||
configPath: options.configPath,
|
||||
dbPath: options.dbPath,
|
||||
stateDbPath: options.stateDbPath,
|
||||
env: options.env || process.env,
|
||||
});
|
||||
const baseQuery = options.query || '';
|
||||
@@ -172,6 +184,7 @@ function createControlPaneServer(options = {}) {
|
||||
ok: true,
|
||||
repoRoot,
|
||||
dbPath: resolvedConfig.dbPath,
|
||||
stateDbPath: resolvedConfig.stateDbPath,
|
||||
allowActions,
|
||||
});
|
||||
return;
|
||||
@@ -181,6 +194,7 @@ function createControlPaneServer(options = {}) {
|
||||
const snapshot = await buildControlPaneSnapshot({
|
||||
repoRoot,
|
||||
dbPath: resolvedConfig.dbPath,
|
||||
stateDbPath: resolvedConfig.stateDbPath,
|
||||
config: resolvedConfig,
|
||||
query: requestUrl.searchParams.get('query') || baseQuery,
|
||||
limit: requestUrl.searchParams.get('limit') || 12,
|
||||
|
||||
@@ -10,6 +10,7 @@ const toml = require('@iarna/toml');
|
||||
const { buildControlPaneActions } = require('./actions');
|
||||
|
||||
const SNAPSHOT_SCHEMA_VERSION = 'ecc.control-pane.snapshot.v1';
|
||||
const DEFAULT_STATE_STORE_RELATIVE_PATH = path.join('.claude', 'ecc', 'state.db');
|
||||
|
||||
function homeDir(env = process.env) {
|
||||
return env.HOME || env.USERPROFILE || os.homedir() || '.';
|
||||
@@ -19,6 +20,10 @@ function defaultDbPath(env = process.env) {
|
||||
return path.join(homeDir(env), '.claude', 'ecc2.db');
|
||||
}
|
||||
|
||||
function defaultStateDbPath(env = process.env) {
|
||||
return path.join(homeDir(env), DEFAULT_STATE_STORE_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
function defaultConfigPaths(cwd = process.cwd(), env = process.env) {
|
||||
const home = homeDir(env);
|
||||
const paths = [
|
||||
@@ -75,11 +80,22 @@ function normalizeMemoryConnectors(connectors = {}) {
|
||||
}
|
||||
|
||||
function normalizeConfig(rawConfig = {}, options = {}) {
|
||||
const { memory_connectors: snakeMemoryConnectors, memoryConnectors, ...rest } = rawConfig;
|
||||
const {
|
||||
memory_connectors: snakeMemoryConnectors,
|
||||
memoryConnectors,
|
||||
state_db_path: snakeStateDbPath,
|
||||
stateDbPath: camelStateDbPath,
|
||||
...rest
|
||||
} = rawConfig;
|
||||
const normalized = normalizeObjectKeys(rest);
|
||||
const connectorConfig = memoryConnectors || snakeMemoryConnectors || normalized.memoryConnectors;
|
||||
return {
|
||||
dbPath: options.dbPath || normalized.dbPath || defaultDbPath(options.env),
|
||||
stateDbPath: options.stateDbPath
|
||||
|| camelStateDbPath
|
||||
|| snakeStateDbPath
|
||||
|| normalized.stateDbPath
|
||||
|| defaultStateDbPath(options.env),
|
||||
memoryConnectors: normalizeMemoryConnectors(connectorConfig),
|
||||
};
|
||||
}
|
||||
@@ -107,6 +123,7 @@ function resolveControlPaneConfig(options = {}) {
|
||||
...normalizeConfig(merged, {
|
||||
env,
|
||||
dbPath: options.dbPath || env.ECC2_DB_PATH || null,
|
||||
stateDbPath: options.stateDbPath || env.ECC_STATE_DB_PATH || null,
|
||||
}),
|
||||
configPaths: configPaths.filter(configPath => fs.existsSync(configPath)),
|
||||
};
|
||||
@@ -437,26 +454,120 @@ function connectorStatus(config, db) {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeWorkItemStatus(status) {
|
||||
const normalized = String(status || 'open').trim().toLowerCase();
|
||||
if (['done', 'closed', 'resolved', 'merged', 'cancelled'].includes(normalized)) return 'done';
|
||||
if (['blocked', 'needs-review', 'failed', 'stalled'].includes(normalized)) return 'blocked';
|
||||
if (['running', 'in-progress', 'active', 'working'].includes(normalized)) return 'running';
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
function normalizeWorkItem(row) {
|
||||
const parsedMetadata = parseJson(row.metadata, {});
|
||||
const metadata = isPlainObject(parsedMetadata) ? normalizeObjectKeys(parsedMetadata) : {};
|
||||
const kanbanState = normalizeWorkItemStatus(row.status);
|
||||
return {
|
||||
id: String(row.id || ''),
|
||||
source: String(row.source || ''),
|
||||
sourceId: row.source_id ? String(row.source_id) : null,
|
||||
title: String(row.title || ''),
|
||||
status: String(row.status || 'open'),
|
||||
kanbanState,
|
||||
priority: row.priority ? String(row.priority) : null,
|
||||
url: row.url ? String(row.url) : null,
|
||||
owner: row.owner ? String(row.owner) : null,
|
||||
repoRoot: row.repo_root ? String(row.repo_root) : null,
|
||||
sessionId: row.session_id ? String(row.session_id) : null,
|
||||
branch: metadata.branch || metadata.headRefName || null,
|
||||
mergeGate: metadata.mergeGate || metadata.mergeGateStatus || metadata.mergeStateStatus || null,
|
||||
blocker: metadata.blocker || null,
|
||||
acceptance: Array.isArray(metadata.acceptance) ? metadata.acceptance.map(String) : [],
|
||||
metadata,
|
||||
createdAt: String(row.created_at || ''),
|
||||
updatedAt: String(row.updated_at || ''),
|
||||
};
|
||||
}
|
||||
|
||||
function readWorkItems(db) {
|
||||
if (!tableExists(db, 'work_items')) return [];
|
||||
return execRows(
|
||||
db,
|
||||
`SELECT *
|
||||
FROM work_items
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 100`
|
||||
).map(normalizeWorkItem);
|
||||
}
|
||||
|
||||
function summarizeWorkItems(items) {
|
||||
const summary = {
|
||||
totalCount: items.length,
|
||||
openCount: 0,
|
||||
blockedCount: 0,
|
||||
doneCount: 0,
|
||||
kanban: {
|
||||
ready: 0,
|
||||
running: 0,
|
||||
blocked: 0,
|
||||
done: 0,
|
||||
},
|
||||
items,
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const kanbanState = normalizeWorkItemStatus(item.kanbanState || item.status);
|
||||
summary.kanban[kanbanState] += 1;
|
||||
if (kanbanState === 'done') {
|
||||
summary.doneCount += 1;
|
||||
} else {
|
||||
summary.openCount += 1;
|
||||
}
|
||||
if (kanbanState === 'blocked') summary.blockedCount += 1;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function readWorkItemsSnapshot(stateDbPath) {
|
||||
let db = null;
|
||||
try {
|
||||
db = await openSqlDatabase(stateDbPath);
|
||||
if (!db) return summarizeWorkItems([]);
|
||||
return summarizeWorkItems(readWorkItems(db));
|
||||
} catch {
|
||||
return summarizeWorkItems([]);
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function buildControlPaneSnapshot(options = {}) {
|
||||
const repoRoot = path.resolve(options.repoRoot || path.join(__dirname, '..', '..', '..'));
|
||||
const config = options.config
|
||||
? normalizeConfig(options.config, {
|
||||
env: options.env || process.env,
|
||||
dbPath: options.dbPath || options.config.dbPath || null,
|
||||
stateDbPath: options.stateDbPath || options.config.stateDbPath || null,
|
||||
})
|
||||
: resolveControlPaneConfig(options);
|
||||
const dbPath = options.dbPath || config.dbPath;
|
||||
const stateDbPath = options.stateDbPath || config.stateDbPath;
|
||||
const query = String(options.query || '').trim();
|
||||
const limit = Math.max(1, Math.min(Number.parseInt(String(options.limit || 12), 10) || 12, 50));
|
||||
const generatedAt = new Date().toISOString();
|
||||
const workItems = await readWorkItemsSnapshot(stateDbPath);
|
||||
const base = {
|
||||
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
||||
generatedAt,
|
||||
repoRoot,
|
||||
dbPath,
|
||||
stateDbPath,
|
||||
database: {
|
||||
exists: Boolean(dbPath && fs.existsSync(dbPath)),
|
||||
},
|
||||
stateDatabase: {
|
||||
exists: Boolean(stateDbPath && fs.existsSync(stateDbPath)),
|
||||
},
|
||||
config: {
|
||||
configPaths: config.configPaths || [],
|
||||
memoryConnectorCount: Object.keys(config.memoryConnectors || {}).length,
|
||||
@@ -473,6 +584,7 @@ async function buildControlPaneSnapshot(options = {}) {
|
||||
results: [],
|
||||
},
|
||||
connectors: connectorStatus(config, null),
|
||||
workItems,
|
||||
actions: buildControlPaneActions({ repoRoot, query, limit }),
|
||||
};
|
||||
|
||||
@@ -513,6 +625,7 @@ module.exports = {
|
||||
SNAPSHOT_SCHEMA_VERSION,
|
||||
buildControlPaneSnapshot,
|
||||
defaultConfigPaths,
|
||||
defaultStateDbPath,
|
||||
recallKnowledgeEntries,
|
||||
resolveControlPaneConfig,
|
||||
};
|
||||
|
||||
@@ -215,6 +215,7 @@ function renderControlPaneHtml() {
|
||||
|
||||
.result,
|
||||
.connector,
|
||||
.work-item,
|
||||
.action {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(52, 64, 56, 0.7);
|
||||
@@ -224,10 +225,42 @@ function renderControlPaneHtml() {
|
||||
|
||||
.result:last-child,
|
||||
.connector:last-child,
|
||||
.work-item:last-child,
|
||||
.action:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.kanban {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(52, 64, 56, 0.7);
|
||||
}
|
||||
|
||||
.kanban-lane {
|
||||
min-width: 0;
|
||||
padding: 9px;
|
||||
border: 1px solid rgba(52, 64, 56, 0.8);
|
||||
border-radius: 6px;
|
||||
background: #141917;
|
||||
}
|
||||
|
||||
.kanban-lane span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.kanban-lane strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -310,6 +343,7 @@ function renderControlPaneHtml() {
|
||||
@media (max-width: 560px) {
|
||||
main { padding: 12px; }
|
||||
.metrics { grid-template-columns: 1fr; }
|
||||
.kanban { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.query { flex-direction: column; }
|
||||
th:nth-child(4), td:nth-child(4) { display: none; }
|
||||
}
|
||||
@@ -338,6 +372,13 @@ function renderControlPaneHtml() {
|
||||
</div>
|
||||
<div id="sessions"></div>
|
||||
</section>
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<h2>Work Items</h2>
|
||||
<span class="subtle" id="work-item-count"></span>
|
||||
</div>
|
||||
<div id="work-items"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="stack">
|
||||
<section>
|
||||
@@ -413,7 +454,13 @@ function renderControlPaneHtml() {
|
||||
|
||||
function statePill(stateName) {
|
||||
const state = String(stateName || 'unknown');
|
||||
const klass = state === 'running' ? 'good' : state === 'failed' ? 'bad' : state === 'pending' ? 'warn' : 'blue';
|
||||
const klass = ['running', 'done'].includes(state)
|
||||
? 'good'
|
||||
: ['failed', 'blocked'].includes(state)
|
||||
? 'bad'
|
||||
: ['pending', 'ready'].includes(state)
|
||||
? 'warn'
|
||||
: 'blue';
|
||||
return '<span class="pill ' + klass + '">' + escapeHtml(state) + '</span>';
|
||||
}
|
||||
|
||||
@@ -446,6 +493,37 @@ function renderControlPaneHtml() {
|
||||
'</tbody></table>';
|
||||
}
|
||||
|
||||
function renderWorkItems(workItems) {
|
||||
const summary = workItems || { totalCount: 0, openCount: 0, blockedCount: 0, doneCount: 0, kanban: {}, items: [] };
|
||||
const items = Array.isArray(summary.items) ? summary.items : [];
|
||||
const kanban = summary.kanban || {};
|
||||
$('#work-item-count').textContent = summary.openCount + ' open / ' + summary.blockedCount + ' blocked';
|
||||
|
||||
const lanes = ['ready', 'running', 'blocked', 'done'];
|
||||
const laneHtml = '<div class="kanban">' + lanes.map(lane =>
|
||||
'<div class="kanban-lane"><span>' + escapeHtml(lane) + '</span><strong>' + escapeHtml(kanban[lane] || 0) + '</strong></div>'
|
||||
).join('') + '</div>';
|
||||
|
||||
if (!items.length) {
|
||||
$('#work-items').innerHTML = laneHtml + '<div class="empty">No agent work items found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$('#work-items').innerHTML = laneHtml + items.slice(0, 8).map(item => {
|
||||
const branch = item.branch || (item.metadata && item.metadata.branch) || '';
|
||||
const mergeGate = item.mergeGate || (item.metadata && item.metadata.mergeGate) || '';
|
||||
const blocker = item.blocker || (item.metadata && item.metadata.blocker) || '';
|
||||
const owner = item.owner || item.source || 'unassigned';
|
||||
return '<div class="work-item">' +
|
||||
'<div class="row"><strong>' + escapeHtml(item.title || item.id) + '</strong>' + statePill(item.kanbanState || item.status) + '</div>' +
|
||||
'<div class="subtle">' + escapeHtml(owner) + ' - ' + escapeHtml(item.source || 'manual') + (item.priority ? ' - ' + escapeHtml(item.priority) : '') + '</div>' +
|
||||
(branch ? '<div class="subtle">branch: ' + escapeHtml(branch) + '</div>' : '') +
|
||||
(mergeGate ? '<div class="subtle">merge gate: ' + escapeHtml(mergeGate) + '</div>' : '') +
|
||||
(blocker ? '<div class="subtle">blocker: ' + escapeHtml(blocker) + '</div>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderKnowledge(knowledge) {
|
||||
$('#knowledge-count').textContent = knowledge.entityCount + ' entities';
|
||||
if (!knowledge.results.length) {
|
||||
@@ -526,6 +604,7 @@ function renderControlPaneHtml() {
|
||||
$('#action-status').textContent = snapshot.execution.allowActions ? 'local allowlist' : 'read-only';
|
||||
renderMetrics(snapshot.summary);
|
||||
renderSessions(snapshot.sessions);
|
||||
renderWorkItems(snapshot.workItems);
|
||||
renderKnowledge(snapshot.knowledge);
|
||||
renderConnectors(snapshot.connectors);
|
||||
renderActions(snapshot.actions.map(action => ({
|
||||
|
||||
Reference in New Issue
Block a user