From 8926ea925e6fac3988fbb8d5097e7fe94f211798 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 11 May 2026 11:51:45 -0400 Subject: [PATCH] feat: track linked work items in status --- README.md | 2 +- schemas/state-store.schema.json | 66 ++++++++++ scripts/lib/state-store/migrations.js | 31 +++++ scripts/lib/state-store/queries.js | 168 +++++++++++++++++++++++++- scripts/lib/state-store/schema.js | 1 + scripts/status.js | 49 +++++++- tests/lib/state-store.test.js | 86 ++++++++++++- 7 files changed, 396 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b4b56c80..ad0f8611 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ This repo is the raw code only. The guides explain everything. - **Media and launch tooling** — `manim-video`, `remotion-video-creation`, and upgraded social publishing surfaces make technical explainers and launch content part of the same system. - **Framework and product surface growth** — `nestjs-patterns`, richer Codex/OpenCode install surfaces, and expanded cross-harness packaging keep the repo usable beyond Claude Code alone. - **ECC 2.0 alpha is in-tree** — the Rust control-plane prototype in `ecc2/` now builds locally and exposes `dashboard`, `start`, `sessions`, `status`, `stop`, `resume`, and `daemon` commands. It is usable as an alpha, not yet a general release. -- **Operator status snapshots** — `ecc status --markdown --write status.md` turns the local state store into a portable handoff covering readiness, active sessions, skill-run health, install health, and pending governance events. +- **Operator status snapshots** — `ecc status --markdown --write status.md` turns the local state store into a portable handoff covering readiness, active sessions, skill-run health, install health, pending governance events, and linked work items from Linear/GitHub/handoffs. - **Ecosystem hardening** — AgentShield, ECC Tools cost controls, billing portal work, and website refreshes continue to ship around the core plugin instead of drifting into separate silos. ### v1.9.0 — Selective Install & Language Expansion (Mar 2026) diff --git a/schemas/state-store.schema.json b/schemas/state-store.schema.json index 74681b8d..70b0e9dc 100644 --- a/schemas/state-store.schema.json +++ b/schemas/state-store.schema.json @@ -40,6 +40,12 @@ "items": { "$ref": "#/$defs/governanceEvent" } + }, + "workItems": { + "type": "array", + "items": { + "$ref": "#/$defs/workItem" + } } }, "$defs": { @@ -311,6 +317,66 @@ "$ref": "#/$defs/nonEmptyString" } } + }, + "workItem": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "source", + "sourceId", + "title", + "status", + "priority", + "url", + "owner", + "repoRoot", + "sessionId", + "metadata", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "source": { + "$ref": "#/$defs/nonEmptyString" + }, + "sourceId": { + "$ref": "#/$defs/nullableString" + }, + "title": { + "$ref": "#/$defs/nonEmptyString" + }, + "status": { + "$ref": "#/$defs/nonEmptyString" + }, + "priority": { + "$ref": "#/$defs/nullableString" + }, + "url": { + "$ref": "#/$defs/nullableString" + }, + "owner": { + "$ref": "#/$defs/nullableString" + }, + "repoRoot": { + "$ref": "#/$defs/nullableString" + }, + "sessionId": { + "$ref": "#/$defs/nullableString" + }, + "metadata": { + "$ref": "#/$defs/jsonValue" + }, + "createdAt": { + "$ref": "#/$defs/nonEmptyString" + }, + "updatedAt": { + "$ref": "#/$defs/nonEmptyString" + } + } } } } diff --git a/scripts/lib/state-store/migrations.js b/scripts/lib/state-store/migrations.js index 7beb9d9c..7716c992 100644 --- a/scripts/lib/state-store/migrations.js +++ b/scripts/lib/state-store/migrations.js @@ -107,12 +107,43 @@ CREATE INDEX IF NOT EXISTS idx_governance_events_session_id_created_at ON governance_events (session_id, created_at DESC); `; +const WORK_ITEMS_SQL = ` +CREATE TABLE IF NOT EXISTS work_items ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + source_id TEXT, + title TEXT NOT NULL, + status TEXT NOT NULL, + priority TEXT, + url TEXT, + owner TEXT, + repo_root TEXT, + session_id TEXT, + metadata TEXT NOT NULL CHECK (json_valid(metadata)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_work_items_status_updated_at + ON work_items (status, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_work_items_source_source_id + ON work_items (source, source_id); +CREATE INDEX IF NOT EXISTS idx_work_items_session_id_updated_at + ON work_items (session_id, updated_at DESC); +`; + const MIGRATIONS = [ { version: 1, name: '001_initial_state_store', sql: INITIAL_SCHEMA_SQL, }, + { + version: 2, + name: '002_work_items', + sql: WORK_ITEMS_SQL, + }, ]; function ensureMigrationTable(db) { diff --git a/scripts/lib/state-store/queries.js b/scripts/lib/state-store/queries.js index d13270a0..7d311777 100644 --- a/scripts/lib/state-store/queries.js +++ b/scripts/lib/state-store/queries.js @@ -5,6 +5,8 @@ const { assertValidEntity } = require('./schema'); const ACTIVE_SESSION_STATES = ['active', 'running', 'idle']; const SUCCESS_OUTCOMES = new Set(['success', 'succeeded', 'passed']); const FAILURE_OUTCOMES = new Set(['failure', 'failed', 'error']); +const CLOSED_WORK_ITEM_STATUSES = new Set(['done', 'closed', 'resolved', 'merged', 'cancelled']); +const ATTENTION_WORK_ITEM_STATUSES = new Set(['blocked', 'needs-review', 'failed', 'stalled']); function normalizeLimit(value, fallback) { if (value === undefined || value === null) { @@ -121,6 +123,24 @@ function mapGovernanceEventRow(row) { }; } +function mapWorkItemRow(row) { + return { + id: row.id, + source: row.source, + sourceId: row.source_id, + title: row.title, + status: row.status, + priority: row.priority, + url: row.url, + owner: row.owner, + repoRoot: row.repo_root, + sessionId: row.session_id, + metadata: parseJsonColumn(row.metadata, null), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + function classifyOutcome(outcome) { const normalized = String(outcome || '').toLowerCase(); if (SUCCESS_OUTCOMES.has(normalized)) { @@ -134,6 +154,19 @@ function classifyOutcome(outcome) { return 'unknown'; } +function classifyWorkItemStatus(status) { + const normalized = String(status || '').toLowerCase(); + if (CLOSED_WORK_ITEM_STATUSES.has(normalized)) { + return 'closed'; + } + + if (ATTENTION_WORK_ITEM_STATUSES.has(normalized)) { + return 'attention'; + } + + return 'open'; +} + function toPercent(numerator, denominator) { if (denominator === 0) { return null; @@ -202,11 +235,36 @@ function summarizeInstallHealth(installations) { }; } -function summarizeReadiness({ activeSessionCount, skillRuns, installHealth, pendingGovernanceCount }) { +function summarizeWorkItems(workItems) { + const summary = { + totalCount: workItems.length, + openCount: 0, + blockedCount: 0, + closedCount: 0, + items: workItems, + }; + + for (const workItem of workItems) { + const classification = classifyWorkItemStatus(workItem.status); + if (classification === 'closed') { + summary.closedCount += 1; + } else if (classification === 'attention') { + summary.openCount += 1; + summary.blockedCount += 1; + } else { + summary.openCount += 1; + } + } + + return summary; +} + +function summarizeReadiness({ activeSessionCount, skillRuns, installHealth, pendingGovernanceCount, workItems }) { const failedSkillRuns = skillRuns.summary.failureCount; const warningInstallations = installHealth.warningCount; const pendingGovernanceEvents = pendingGovernanceCount; - const attentionCount = failedSkillRuns + warningInstallations + pendingGovernanceEvents; + const blockedWorkItems = workItems.blockedCount; + const attentionCount = failedSkillRuns + warningInstallations + pendingGovernanceEvents + blockedWorkItems; return { status: attentionCount > 0 ? 'attention' : 'ok', @@ -215,6 +273,7 @@ function summarizeReadiness({ activeSessionCount, skillRuns, installHealth, pend failedSkillRuns, warningInstallations, pendingGovernanceEvents, + blockedWorkItems, }; } @@ -301,6 +360,25 @@ function normalizeGovernanceEventInput(governanceEvent) { }; } +function normalizeWorkItemInput(workItem) { + const now = new Date().toISOString(); + return { + id: workItem.id, + source: workItem.source, + sourceId: workItem.sourceId ?? null, + title: workItem.title, + status: workItem.status, + priority: workItem.priority ?? null, + url: workItem.url ?? null, + owner: workItem.owner ?? null, + repoRoot: workItem.repoRoot ?? null, + sessionId: workItem.sessionId ?? null, + metadata: workItem.metadata ?? null, + createdAt: workItem.createdAt || now, + updatedAt: workItem.updatedAt || now, + }; +} + function createQueryApi(db) { const listRecentSessionsStatement = db.prepare(` SELECT * @@ -366,6 +444,22 @@ function createQueryApi(db) { ORDER BY created_at DESC, id DESC LIMIT ? `); + const listWorkItemsStatement = db.prepare(` + SELECT * + FROM work_items + ORDER BY updated_at DESC, id DESC + LIMIT ? + `); + const listAllWorkItemsStatement = db.prepare(` + SELECT * + FROM work_items + ORDER BY updated_at DESC, id DESC + `); + const getWorkItemStatement = db.prepare(` + SELECT * + FROM work_items + WHERE id = ? + `); const getSkillVersionStatement = db.prepare(` SELECT * FROM skill_versions @@ -547,6 +641,50 @@ function createQueryApi(db) { created_at = excluded.created_at `); + const upsertWorkItemStatement = db.prepare(` + INSERT INTO work_items ( + id, + source, + source_id, + title, + status, + priority, + url, + owner, + repo_root, + session_id, + metadata, + created_at, + updated_at + ) VALUES ( + @id, + @source, + @source_id, + @title, + @status, + @priority, + @url, + @owner, + @repo_root, + @session_id, + @metadata, + @created_at, + @updated_at + ) + ON CONFLICT(id) DO UPDATE SET + source = excluded.source, + source_id = excluded.source_id, + title = excluded.title, + status = excluded.status, + priority = excluded.priority, + url = excluded.url, + owner = excluded.owner, + repo_root = excluded.repo_root, + session_id = excluded.session_id, + metadata = excluded.metadata, + updated_at = excluded.updated_at + `); + function getSessionById(id) { const row = getSessionStatement.get(id); return row ? mapSessionRow(row) : null; @@ -582,12 +720,15 @@ function createQueryApi(db) { const activeLimit = normalizeLimit(options.activeLimit, 5); const recentSkillRunLimit = normalizeLimit(options.recentSkillRunLimit, 20); const pendingLimit = normalizeLimit(options.pendingLimit, 5); + const workItemLimit = normalizeLimit(options.workItemLimit, 10); const activeSessions = listActiveSessionsStatement.all(activeLimit).map(mapSessionRow); const activeSessionCount = countActiveSessionsStatement.get().total_count; const recentSkillRuns = listRecentSkillRunsStatement.all(recentSkillRunLimit).map(mapSkillRunRow); const installations = listInstallStateStatement.all().map(mapInstallStateRow); const pendingGovernanceEvents = listPendingGovernanceStatement.all(pendingLimit).map(mapGovernanceEventRow); + const workItems = summarizeWorkItems(listAllWorkItemsStatement.all().map(mapWorkItemRow)); + workItems.items = listWorkItemsStatement.all(workItemLimit).map(mapWorkItemRow); const skillRuns = { windowSize: recentSkillRunLimit, summary: summarizeSkillRuns(recentSkillRuns), @@ -603,6 +744,7 @@ function createQueryApi(db) { skillRuns, installHealth, pendingGovernanceCount, + workItems, }), activeSessions: { activeCount: activeSessionCount, @@ -614,6 +756,7 @@ function createQueryApi(db) { pendingCount: pendingGovernanceCount, events: pendingGovernanceEvents, }, + workItems, }; } @@ -683,6 +826,27 @@ function createQueryApi(db) { }); return normalized; }, + upsertWorkItem(workItem) { + const normalized = normalizeWorkItemInput(workItem); + assertValidEntity('workItem', normalized); + upsertWorkItemStatement.run({ + id: normalized.id, + source: normalized.source, + source_id: normalized.sourceId, + title: normalized.title, + status: normalized.status, + priority: normalized.priority, + url: normalized.url, + owner: normalized.owner, + repo_root: normalized.repoRoot, + session_id: normalized.sessionId, + metadata: stringifyJson(normalized.metadata, 'workItem.metadata'), + created_at: normalized.createdAt, + updated_at: normalized.updatedAt, + }); + const row = getWorkItemStatement.get(normalized.id); + return row ? mapWorkItemRow(row) : null; + }, upsertSession(session) { const normalized = normalizeSessionInput(session); assertValidEntity('session', normalized); diff --git a/scripts/lib/state-store/schema.js b/scripts/lib/state-store/schema.js index 2342fc2a..915e0481 100644 --- a/scripts/lib/state-store/schema.js +++ b/scripts/lib/state-store/schema.js @@ -13,6 +13,7 @@ const ENTITY_DEFINITIONS = { decision: 'decision', installState: 'installState', governanceEvent: 'governanceEvent', + workItem: 'workItem', }; let cachedSchema = null; diff --git a/scripts/status.js b/scripts/status.js index ed248ac9..c6c9514a 100644 --- a/scripts/status.js +++ b/scripts/status.js @@ -11,7 +11,7 @@ function showHelp(exitCode = 0) { Usage: node scripts/status.js [--db ] [--json|--markdown] [--write ] [--limit ] Query the ECC SQLite state store for active sessions, recent skill runs, -install health, and pending governance events. +install health, pending governance events, and linked work items. `); process.exit(exitCode); } @@ -142,6 +142,24 @@ function printGovernance(section) { } } +function printWorkItems(section) { + console.log(`Work items: ${section.openCount} open, ${section.blockedCount} blocked, ${section.closedCount} closed`); + if (section.items.length === 0) { + console.log(' - none'); + return; + } + + for (const item of section.items.slice(0, 10)) { + const sourceId = item.sourceId ? `#${item.sourceId}` : item.id; + console.log(` - ${item.source}/${sourceId} ${item.status}: ${item.title}`); + console.log(` Owner: ${item.owner || '(unassigned)'}`); + console.log(` Updated: ${item.updatedAt}`); + if (item.url) { + console.log(` URL: ${item.url}`); + } + } +} + function printReadiness(section) { console.log(`Readiness: ${section.status}`); console.log(` Attention items: ${section.attentionCount}`); @@ -149,6 +167,7 @@ function printReadiness(section) { console.log(` Failed skill runs: ${section.failedSkillRuns}`); console.log(` Warning installs: ${section.warningInstallations}`); console.log(` Pending governance: ${section.pendingGovernanceEvents}`); + console.log(` Blocked work items: ${section.blockedWorkItems}`); } function printHuman(payload) { @@ -163,6 +182,8 @@ function printHuman(payload) { printInstallHealth(payload.installHealth); console.log(); printGovernance(payload.governance); + console.log(); + printWorkItems(payload.workItems); } function formatPercent(value) { @@ -188,6 +209,7 @@ function renderMarkdown(payload) { `Failed skill runs: ${payload.readiness.failedSkillRuns}`, `Warning installs: ${payload.readiness.warningInstallations}`, `Pending governance: ${payload.readiness.pendingGovernanceEvents}`, + `Blocked work items: ${payload.readiness.blockedWorkItems}`, '', '## Active Sessions', '', @@ -267,6 +289,30 @@ function renderMarkdown(payload) { } } + lines.push( + '', + '## Work Items', + '', + `Open: ${payload.workItems.openCount}`, + `Blocked: ${payload.workItems.blockedCount}`, + `Closed: ${payload.workItems.closedCount}` + ); + + if (payload.workItems.items.length === 0) { + lines.push('', '- none'); + } else { + lines.push('', 'Recent work items:'); + for (const item of payload.workItems.items.slice(0, 10)) { + const sourceId = item.sourceId ? `#${item.sourceId}` : item.id; + lines.push(`- ${formatCode(item.source)} ${formatCode(sourceId)} ${item.status}: ${item.title}`); + lines.push(` - Owner: ${item.owner || '(unassigned)'}`); + lines.push(` - Updated: ${item.updatedAt}`); + if (item.url) { + lines.push(` - URL: ${item.url}`); + } + } + } + return `${lines.join('\n')}\n`; } @@ -296,6 +342,7 @@ async function main() { activeLimit: options.limit, recentSkillRunLimit: 20, pendingLimit: options.limit, + workItemLimit: options.limit, }), }; diff --git a/tests/lib/state-store.test.js b/tests/lib/state-store.test.js index 64c0b5fd..b46030d1 100644 --- a/tests/lib/state-store.test.js +++ b/tests/lib/state-store.test.js @@ -269,15 +269,16 @@ async function runTests() { const firstMigrations = firstStore.getAppliedMigrations(); firstStore.close(); - assert.strictEqual(firstMigrations.length, 1); + assert.strictEqual(firstMigrations.length, 2); assert.strictEqual(firstMigrations[0].version, 1); + assert.strictEqual(firstMigrations[1].version, 2); assert.ok(fs.existsSync(expectedPath)); const secondStore = await createStateStore({ homeDir }); const secondMigrations = secondStore.getAppliedMigrations(); secondStore.close(); - assert.strictEqual(secondMigrations.length, 1); + assert.strictEqual(secondMigrations.length, 2); assert.strictEqual(secondMigrations[0].version, 1); } finally { cleanupTempDir(homeDir); @@ -294,7 +295,7 @@ async function runTests() { const store = await createStateStore({ dbPath: ':memory:' }); assert.strictEqual(store.dbPath, ':memory:'); - assert.strictEqual(store.getAppliedMigrations().length, 1); + assert.strictEqual(store.getAppliedMigrations().length, 2); store.close(); assert.ok(!fs.existsSync(path.join(tempDir, ':memory:'))); @@ -345,6 +346,7 @@ async function runTests() { assert.strictEqual(status.readiness.failedSkillRuns, 1); assert.strictEqual(status.readiness.warningInstallations, 0); assert.strictEqual(status.readiness.pendingGovernanceEvents, 1); + assert.strictEqual(status.readiness.blockedWorkItems, 0); assert.strictEqual(status.activeSessions.activeCount, 1); assert.strictEqual(status.activeSessions.sessions[0].id, 'session-active'); assert.strictEqual(status.skillRuns.summary.totalCount, 4); @@ -355,6 +357,7 @@ async function runTests() { assert.strictEqual(status.installHealth.totalCount, 1); assert.strictEqual(status.governance.pendingCount, 1); assert.strictEqual(status.governance.events[0].id, 'gov-1'); + assert.strictEqual(status.workItems.openCount, 0); } finally { cleanupTempDir(testDir); } @@ -385,6 +388,80 @@ async function runTests() { assert.deepStrictEqual(status.installHealth.installations, []); assert.strictEqual(status.governance.pendingCount, 0); assert.deepStrictEqual(status.governance.events, []); + assert.strictEqual(status.workItems.totalCount, 0); + assert.deepStrictEqual(status.workItems.items, []); + } finally { + cleanupTempDir(testDir); + } + })) passed += 1; else failed += 1; + + if (await test('tracks linked work items for Linear, GitHub, and handoff progress', async () => { + const testDir = createTempDir('ecc-state-work-items-'); + const dbPath = path.join(testDir, 'state.db'); + + try { + await seedStore(dbPath); + + const store = await createStateStore({ dbPath }); + const linearItem = store.upsertWorkItem({ + id: 'linear-ecc-20-control-plane', + source: 'linear', + sourceId: 'ECC-20', + title: 'Define harness-neutral session/worktree contract', + status: 'in-progress', + priority: 'high', + url: 'https://linear.app/ecctools/issue/ECC-20', + owner: 'control-plane', + repoRoot: '/tmp/ecc-repo', + sessionId: 'session-active', + metadata: { + project: 'ECC 2.0: Control Plane', + }, + createdAt: '2026-03-15T08:12:00.000Z', + updatedAt: '2026-03-15T08:15:00.000Z', + }); + + store.upsertWorkItem({ + id: 'handoff-release-gate', + source: 'handoff', + sourceId: 'ecc-rc1-release-decision-20260511.md', + title: 'Rerun rc.1 release gate before tag', + status: 'blocked', + priority: 'high', + owner: 'release', + repoRoot: '/tmp/ecc-repo', + metadata: { + blocker: 'tag decision pending', + }, + createdAt: '2026-03-15T08:13:00.000Z', + updatedAt: '2026-03-15T08:16:00.000Z', + }); + + store.upsertWorkItem({ + id: 'github-pr-1738', + source: 'github', + sourceId: '1738', + title: 'Add Qwen install target', + status: 'merged', + priority: 'normal', + url: 'https://github.com/affaan-m/everything-claude-code/pull/1738', + owner: 'maintainer', + createdAt: '2026-03-15T08:14:00.000Z', + updatedAt: '2026-03-15T08:17:00.000Z', + }); + + const status = store.getStatus(); + store.close(); + + assert.strictEqual(linearItem.id, 'linear-ecc-20-control-plane'); + assert.strictEqual(linearItem.metadata.project, 'ECC 2.0: Control Plane'); + assert.strictEqual(status.workItems.totalCount, 3); + assert.strictEqual(status.workItems.openCount, 2); + assert.strictEqual(status.workItems.blockedCount, 1); + assert.strictEqual(status.workItems.closedCount, 1); + assert.strictEqual(status.readiness.blockedWorkItems, 1); + assert.strictEqual(status.readiness.attentionCount, 3); + assert.strictEqual(status.workItems.items[0].id, 'github-pr-1738'); } finally { cleanupTempDir(testDir); } @@ -608,10 +685,13 @@ async function runTests() { assert.match(written, /## Readiness/); assert.match(written, /Status: attention/); assert.match(written, /Attention items: 2/); + assert.match(written, /Blocked work items: 0/); assert.match(written, /- `session-active` \[claude\/dmux-tmux\] active/); assert.match(written, /Success rate: 66\.7%/); assert.match(written, /Install health: healthy/); assert.match(written, /Pending governance events: 1/); + assert.match(written, /## Work Items/); + assert.match(written, /Open: 0/); } finally { cleanupTempDir(testDir); }