diff --git a/README.md b/README.md index ad0f8611..ff547c61 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, pending governance events, and linked work items from Linear/GitHub/handoffs. +- **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. Use `ecc work-items upsert ...` to add or update those linked work items from the CLI. - **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/package.json b/package.json index 2b759fe4..fedd09b6 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "scripts/setup-package-manager.js", "scripts/skill-create-output.js", "scripts/status.js", + "scripts/work-items.js", "scripts/uninstall.js", "skills/agent-harness-construction/", "skills/agent-introspection-debugging/", diff --git a/scripts/ecc.js b/scripts/ecc.js index f3473295..fecfadea 100755 --- a/scripts/ecc.js +++ b/scripts/ecc.js @@ -49,6 +49,10 @@ const COMMANDS = { script: 'sessions-cli.js', description: 'List or inspect ECC sessions from the SQLite state store', }, + 'work-items': { + script: 'work-items.js', + description: 'Track linked Linear, GitHub, handoff, and manual work items', + }, 'session-inspect': { script: 'session-inspect.js', description: 'Emit canonical ECC session snapshots from dmux or Claude history targets', @@ -74,6 +78,7 @@ const PRIMARY_COMMANDS = [ 'auto-update', 'status', 'sessions', + 'work-items', 'session-inspect', 'loop-status', 'uninstall', @@ -111,6 +116,7 @@ Examples: ecc status --markdown --write status.md ecc sessions ecc sessions session-active --json + ecc work-items upsert linear-ecc-20 --source linear --source-id ECC-20 --title "Review control-plane contract" --status blocked ecc session-inspect claude:latest ecc loop-status --json ecc uninstall --target antigravity --dry-run diff --git a/scripts/lib/state-store/queries.js b/scripts/lib/state-store/queries.js index 7d311777..b265fc12 100644 --- a/scripts/lib/state-store/queries.js +++ b/scripts/lib/state-store/queries.js @@ -450,6 +450,10 @@ function createQueryApi(db) { ORDER BY updated_at DESC, id DESC LIMIT ? `); + const countWorkItemsStatement = db.prepare(` + SELECT COUNT(*) AS total_count + FROM work_items + `); const listAllWorkItemsStatement = db.prepare(` SELECT * FROM work_items @@ -690,6 +694,11 @@ function createQueryApi(db) { return row ? mapSessionRow(row) : null; } + function getWorkItemById(id) { + const row = getWorkItemStatement.get(id); + return row ? mapWorkItemRow(row) : null; + } + function listRecentSessions(options = {}) { const limit = normalizeLimit(options.limit, 10); return { @@ -716,6 +725,14 @@ function createQueryApi(db) { }; } + function listWorkItems(options = {}) { + const limit = normalizeLimit(options.limit, 20); + return { + totalCount: countWorkItemsStatement.get().total_count, + items: listWorkItemsStatement.all(limit).map(mapWorkItemRow), + }; + } + function getStatus(options = {}) { const activeLimit = normalizeLimit(options.activeLimit, 5); const recentSkillRunLimit = normalizeLimit(options.recentSkillRunLimit, 20); @@ -763,6 +780,7 @@ function createQueryApi(db) { return { getSessionById, getSessionDetail, + getWorkItemById, getStatus, insertDecision(decision) { const normalized = normalizeDecisionInput(decision); @@ -812,6 +830,7 @@ function createQueryApi(db) { return normalized; }, listRecentSessions, + listWorkItems, upsertInstallState(installState) { const normalized = normalizeInstallStateInput(installState); assertValidEntity('installState', normalized); diff --git a/scripts/work-items.js b/scripts/work-items.js new file mode 100644 index 00000000..86a58361 --- /dev/null +++ b/scripts/work-items.js @@ -0,0 +1,291 @@ +#!/usr/bin/env node +'use strict'; + +const os = require('os'); +const { createStateStore } = require('./lib/state-store'); + +const VALUE_FLAGS = new Set([ + '--db', + '--id', + '--limit', + '--metadata-json', + '--owner', + '--priority', + '--repo', + '--repo-root', + '--session', + '--session-id', + '--source', + '--source-id', + '--status', + '--title', + '--url', +]); + +function showHelp(exitCode = 0) { + console.log(` +Usage: + node scripts/work-items.js list [--db ] [--json] [--limit ] + node scripts/work-items.js show [--db ] [--json] + node scripts/work-items.js upsert [] --title [options] [--json] + node scripts/work-items.js close <id> [--status done] [--db <path>] [--json] + +Track Linear, GitHub, handoff, and manual roadmap items in the ECC SQLite state +store so "ecc status" can include linked work and blocked operator follow-up. + +Options: + --id <id> Stable local work-item id for upsert + --source <source> Source system, e.g. linear, github, handoff, manual + --source-id <id> Source-local identifier, e.g. ECC-20 or PR number + --status <status> Status such as open, in-progress, blocked, done + --priority <priority> Optional priority label + --url <url> Optional source URL + --owner <owner> Optional owner label + --repo-root <path> Optional repo root to associate with this item + --repo <path> Alias for --repo-root + --session-id <id> Optional ECC session id + --session <id> Alias for --session-id + --metadata-json <json> Optional JSON metadata payload + --db <path> SQLite state database path + --json Emit JSON +`); + process.exit(exitCode); +} + +function assignOption(options, flag, value) { + if (flag === '--db') options.dbPath = value; + else if (flag === '--id') options.id = value; + else if (flag === '--limit') options.limit = value; + else if (flag === '--metadata-json') options.metadataJson = value; + else if (flag === '--owner') options.owner = value; + else if (flag === '--priority') options.priority = value; + else if (flag === '--repo' || flag === '--repo-root') options.repoRoot = value; + else if (flag === '--session' || flag === '--session-id') options.sessionId = value; + else if (flag === '--source') options.source = value; + else if (flag === '--source-id') options.sourceId = value; + else if (flag === '--status') options.status = value; + else if (flag === '--title') options.title = value; + else if (flag === '--url') options.url = value; + else throw new Error(`Unknown argument: ${flag}`); +} + +function parseArgs(argv) { + const args = argv.slice(2); + const parsed = { + command: 'list', + dbPath: null, + help: false, + json: false, + limit: 20, + positionals: [], + }; + + if (args[0] && !args[0].startsWith('-')) { + parsed.command = args.shift(); + } + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--help' || arg === '-h') { + parsed.help = true; + } else if (arg === '--json') { + parsed.json = true; + } else if (VALUE_FLAGS.has(arg)) { + const value = args[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for ${arg}`); + } + assignOption(parsed, arg, value); + index += 1; + } else if (!arg.startsWith('-')) { + parsed.positionals.push(arg); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + return parsed; +} + +function parseMetadataJson(value) { + if (value === undefined || value === null) { + return null; + } + + try { + return JSON.parse(value); + } catch (error) { + throw new Error(`Invalid --metadata-json: ${error.message}`); + } +} + +function resolveWorkItemId(options) { + return options.id || options.positionals[0] || null; +} + +function normalizeLimit(value) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid limit: ${value}`); + } + return parsed; +} + +function buildUpsertPayload(options, existing = null) { + const id = resolveWorkItemId(options); + if (!id) { + throw new Error('Missing work item id. Pass <id> or --id <id>.'); + } + + const title = options.title ?? (existing && existing.title); + if (!title) { + throw new Error('Missing --title for a new work item.'); + } + + return { + id, + source: options.source ?? (existing && existing.source) ?? 'manual', + sourceId: options.sourceId ?? (existing && existing.sourceId) ?? null, + title, + status: options.status ?? (existing && existing.status) ?? 'open', + priority: options.priority ?? (existing && existing.priority) ?? null, + url: options.url ?? (existing && existing.url) ?? null, + owner: options.owner ?? (existing && existing.owner) ?? null, + repoRoot: options.repoRoot ?? (existing && existing.repoRoot) ?? process.cwd(), + sessionId: options.sessionId ?? (existing && existing.sessionId) ?? null, + metadata: options.metadataJson !== undefined + ? parseMetadataJson(options.metadataJson) + : ((existing && existing.metadata) ?? null), + createdAt: existing ? existing.createdAt : undefined, + updatedAt: new Date().toISOString(), + }; +} + +function printWorkItem(item) { + const sourceId = item.sourceId ? `#${item.sourceId}` : item.id; + console.log(`${item.source}/${sourceId} ${item.status}: ${item.title}`); + console.log(`ID: ${item.id}`); + console.log(`Priority: ${item.priority || '(none)'}`); + console.log(`Owner: ${item.owner || '(unassigned)'}`); + console.log(`Repo: ${item.repoRoot || '(none)'}`); + console.log(`Session: ${item.sessionId || '(none)'}`); + console.log(`Updated: ${item.updatedAt}`); + if (item.url) { + console.log(`URL: ${item.url}`); + } +} + +function printWorkItemList(payload) { + console.log(`Work items: ${payload.items.length} shown / ${payload.totalCount} total`); + if (payload.items.length === 0) { + console.log(' - none'); + return; + } + + for (const item of payload.items) { + const sourceId = item.sourceId ? `#${item.sourceId}` : item.id; + console.log(` - ${item.source}/${sourceId} ${item.status}: ${item.title}`); + console.log(` ID: ${item.id}`); + console.log(` Owner: ${item.owner || '(unassigned)'}`); + console.log(` Updated: ${item.updatedAt}`); + if (item.url) { + console.log(` URL: ${item.url}`); + } + } +} + +async function main() { + let store = null; + + try { + const options = parseArgs(process.argv); + if (options.help) { + showHelp(0); + } + + store = await createStateStore({ + dbPath: options.dbPath, + homeDir: process.env.HOME || os.homedir(), + }); + + if (options.command === 'list') { + const payload = store.listWorkItems({ limit: normalizeLimit(options.limit) }); + if (options.json) { + console.log(JSON.stringify(payload, null, 2)); + } else { + printWorkItemList(payload); + } + return; + } + + if (options.command === 'show') { + const id = resolveWorkItemId(options); + if (!id) { + throw new Error('Missing work item id.'); + } + const item = store.getWorkItemById(id); + if (!item) { + throw new Error(`Work item not found: ${id}`); + } + if (options.json) { + console.log(JSON.stringify(item, null, 2)); + } else { + printWorkItem(item); + } + return; + } + + if (options.command === 'upsert') { + const id = resolveWorkItemId(options); + const existing = id ? store.getWorkItemById(id) : null; + const item = store.upsertWorkItem(buildUpsertPayload(options, existing)); + if (options.json) { + console.log(JSON.stringify(item, null, 2)); + } else { + printWorkItem(item); + } + return; + } + + if (options.command === 'close') { + const id = resolveWorkItemId(options); + if (!id) { + throw new Error('Missing work item id.'); + } + const existing = store.getWorkItemById(id); + if (!existing) { + throw new Error(`Work item not found: ${id}`); + } + const item = store.upsertWorkItem(buildUpsertPayload({ + ...options, + id, + status: options.status || 'done', + }, existing)); + if (options.json) { + console.log(JSON.stringify(item, null, 2)); + } else { + printWorkItem(item); + } + return; + } + + throw new Error(`Unknown command: ${options.command}`); + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } finally { + if (store) { + store.close(); + } + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + buildUpsertPayload, + main, + parseArgs, +}; diff --git a/tests/lib/state-store.test.js b/tests/lib/state-store.test.js index b46030d1..9c9ee940 100644 --- a/tests/lib/state-store.test.js +++ b/tests/lib/state-store.test.js @@ -16,6 +16,7 @@ const { const ECC_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'ecc.js'); const STATUS_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'status.js'); const SESSIONS_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'sessions-cli.js'); +const WORK_ITEMS_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'work-items.js'); async function test(name, fn) { try { @@ -697,6 +698,76 @@ async function runTests() { } })) passed += 1; else failed += 1; + if (await test('work-items CLI supports upsert, list, show, and close', async () => { + const testDir = createTempDir('ecc-work-items-cli-'); + const dbPath = path.join(testDir, 'state.db'); + + try { + const upsertResult = runNode(WORK_ITEMS_SCRIPT, [ + 'upsert', + 'linear-ecc-99', + '--db', + dbPath, + '--source', + 'linear', + '--source-id', + 'ECC-99', + '--title', + 'Ship work item CLI', + '--status', + 'blocked', + '--priority', + 'high', + '--url', + 'https://linear.app/example/issue/ECC-99', + '--owner', + 'control-plane', + '--metadata-json', + '{"project":"ECC 2.0"}', + '--json', + ], { cwd: testDir }); + assert.strictEqual(upsertResult.status, 0, upsertResult.stderr); + const upsertPayload = parseJson(upsertResult.stdout); + assert.strictEqual(upsertPayload.id, 'linear-ecc-99'); + assert.strictEqual(upsertPayload.status, 'blocked'); + assert.strictEqual(upsertPayload.repoRoot, fs.realpathSync(testDir)); + assert.strictEqual(upsertPayload.metadata.project, 'ECC 2.0'); + + const updateResult = runNode(WORK_ITEMS_SCRIPT, [ + 'upsert', + 'linear-ecc-99', + '--db', + dbPath, + '--status', + 'in-progress', + '--json', + ]); + assert.strictEqual(updateResult.status, 0, updateResult.stderr); + const updatePayload = parseJson(updateResult.stdout); + assert.strictEqual(updatePayload.title, 'Ship work item CLI'); + assert.strictEqual(updatePayload.source, 'linear'); + assert.strictEqual(updatePayload.status, 'in-progress'); + + const listResult = runNode(WORK_ITEMS_SCRIPT, ['list', '--db', dbPath, '--json']); + assert.strictEqual(listResult.status, 0, listResult.stderr); + const listPayload = parseJson(listResult.stdout); + assert.strictEqual(listPayload.totalCount, 1); + assert.strictEqual(listPayload.items[0].id, 'linear-ecc-99'); + + const showResult = runNode(WORK_ITEMS_SCRIPT, ['show', 'linear-ecc-99', '--db', dbPath]); + assert.strictEqual(showResult.status, 0, showResult.stderr); + assert.match(showResult.stdout, /linear\/#ECC-99 in-progress: Ship work item CLI/); + + const closeResult = runNode(WORK_ITEMS_SCRIPT, ['close', 'linear-ecc-99', '--db', dbPath, '--json']); + assert.strictEqual(closeResult.status, 0, closeResult.stderr); + const closePayload = parseJson(closeResult.stdout); + assert.strictEqual(closePayload.status, 'done'); + assert.strictEqual(closePayload.title, 'Ship work item CLI'); + } finally { + cleanupTempDir(testDir); + } + })) passed += 1; else failed += 1; + if (await test('sessions CLI supports list and detail views in human-readable and --json output', async () => { const testDir = createTempDir('ecc-state-cli-'); const dbPath = path.join(testDir, 'state.db'); @@ -729,7 +800,7 @@ async function runTests() { } })) passed += 1; else failed += 1; - if (await test('ecc CLI delegates the new status and sessions subcommands', async () => { + if (await test('ecc CLI delegates the new status, sessions, and work-items subcommands', async () => { const testDir = createTempDir('ecc-state-cli-'); const dbPath = path.join(testDir, 'state.db'); @@ -746,6 +817,29 @@ async function runTests() { const sessionsPayload = parseJson(sessionsResult.stdout); assert.strictEqual(sessionsPayload.session.id, 'session-active'); assert.strictEqual(sessionsPayload.skillRuns.length, 2); + + const workItemResult = runNode(ECC_SCRIPT, [ + 'work-items', + 'upsert', + 'handoff-roadmap', + '--db', + dbPath, + '--source', + 'handoff', + '--title', + 'Track roadmap handoff', + '--status', + 'blocked', + '--json', + ], { cwd: testDir }); + assert.strictEqual(workItemResult.status, 0, workItemResult.stderr); + const workItemPayload = parseJson(workItemResult.stdout); + assert.strictEqual(workItemPayload.id, 'handoff-roadmap'); + + const delegatedStatusResult = runNode(ECC_SCRIPT, ['status', '--db', dbPath, '--json']); + assert.strictEqual(delegatedStatusResult.status, 0, delegatedStatusResult.stderr); + const delegatedStatusPayload = parseJson(delegatedStatusResult.stdout); + assert.strictEqual(delegatedStatusPayload.readiness.blockedWorkItems, 1); } finally { cleanupTempDir(testDir); } diff --git a/tests/scripts/ecc.test.js b/tests/scripts/ecc.test.js index 2691f83a..c380623a 100644 --- a/tests/scripts/ecc.test.js +++ b/tests/scripts/ecc.test.js @@ -71,6 +71,7 @@ function main() { assert.match(result.stdout, /auto-update/); assert.match(result.stdout, /consult/); assert.match(result.stdout, /loop-status/); + assert.match(result.stdout, /work-items/); }], ['delegates explicit install command', () => { const result = runCli(['install', '--dry-run', '--json', 'typescript']); @@ -201,6 +202,11 @@ function main() { assert.strictEqual(result.status, 0, result.stderr); assert.match(result.stdout, /node scripts\/consult\.js "security reviews"/); }], + ['supports help for the work-items subcommand', () => { + const result = runCli(['help', 'work-items']); + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stdout, /node scripts\/work-items\.js upsert/); + }], ['fails on unknown commands instead of treating them as installs', () => { const result = runCli(['bogus']); assert.strictEqual(result.status, 1); diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index bd4bc1e8..03b0d710 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -48,6 +48,7 @@ function buildExpectedPublishPaths(repoRoot) { "scripts/doctor.js", "scripts/status.js", "scripts/sessions-cli.js", + "scripts/work-items.js", "scripts/install-apply.js", "scripts/install-plan.js", "scripts/list-installed.js", @@ -110,6 +111,7 @@ function main() { for (const requiredPath of [ "scripts/catalog.js", "scripts/consult.js", + "scripts/work-items.js", ".gemini/GEMINI.md", ".qwen/QWEN.md", ".claude-plugin/plugin.json",