From 2b2777915e4057a1ee5fd63cf35d61843058b521 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 14 Mar 2026 19:09:26 -0700 Subject: [PATCH] feat: expand session adapter registry with structured targets - Registry accepts { type, value } structured targets - Add --list-adapters and --target-type CLI flags to session-inspect - Export adapter type from claude-history and dmux-tmux adapters - 71 new session adapter tests, 34 new session-inspect tests - All 1142 tests passing --- package-lock.json | 36 +++++---- .../lib/session-adapters/claude-history.js | 2 + scripts/lib/session-adapters/dmux-tmux.js | 2 + scripts/lib/session-adapters/registry.js | 77 ++++++++++++++++++- scripts/session-inspect.js | 23 ++++-- tests/lib/session-adapters.test.js | 71 +++++++++++++++++ tests/scripts/session-inspect.test.js | 34 ++++++++ 7 files changed, 221 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 60bb15f7..e22a374c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "MIT", "bin": { + "ecc": "scripts/ecc.js", "ecc-install": "install.sh" }, "devDependencies": { @@ -18,7 +19,7 @@ "c8": "^10.1.2", "eslint": "^9.39.2", "globals": "^17.1.0", - "markdownlint-cli": "^0.48.0" + "markdownlint-cli": "^0.47.0" }, "engines": { "node": ">=18" @@ -1655,22 +1656,23 @@ } }, "node_modules/markdownlint-cli": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/markdownlint-cli/-/markdownlint-cli-0.48.0.tgz", - "integrity": "sha512-NkZQNu2E0Q5qLEEHwWj674eYISTLD4jMHkBzDobujXd1kv+yCxi8jOaD/rZoQNW1FBBMMGQpuW5So8B51N/e0A==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/markdownlint-cli/-/markdownlint-cli-0.47.0.tgz", + "integrity": "sha512-HOcxeKFAdDoldvoYDofd85vI8LgNWy8vmYpCwnlLV46PJcodmGzD7COSSBlhHwsfT4o9KrAStGodImVBus31Bg==", "dev": true, + "license": "MIT", "dependencies": { - "commander": "~14.0.3", + "commander": "~14.0.2", "deep-extend": "~0.6.0", "ignore": "~7.0.5", "js-yaml": "~4.1.1", "jsonc-parser": "~3.3.1", "jsonpointer": "~5.0.1", - "markdown-it": "~14.1.1", + "markdown-it": "~14.1.0", "markdownlint": "~0.40.0", - "minimatch": "~10.2.4", + "minimatch": "~10.1.1", "run-con": "~1.3.2", - "smol-toml": "~1.6.0", + "smol-toml": "~1.5.2", "tinyglobby": "~0.2.15" }, "bin": { @@ -1685,6 +1687,7 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, + "license": "MIT", "engines": { "node": "18 || 20 || >=22" } @@ -1694,6 +1697,7 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, @@ -1712,15 +1716,16 @@ } }, "node_modules/markdownlint-cli/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.3.tgz", + "integrity": "sha512-IF6URNyBX7Z6XfvjpaNy5meRxPZiIf2OqtOoSLs+hLJ9pJAScnM1RjrFcbCaD85y42KcI+oZmKjFIJKYDFjQfg==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2579,10 +2584,11 @@ } }, "node_modules/smol-toml": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", - "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.5.2.tgz", + "integrity": "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">= 18" }, diff --git a/scripts/lib/session-adapters/claude-history.js b/scripts/lib/session-adapters/claude-history.js index 60641df3..c6ff7f6d 100644 --- a/scripts/lib/session-adapters/claude-history.js +++ b/scripts/lib/session-adapters/claude-history.js @@ -114,6 +114,8 @@ function resolveSessionRecord(target, cwd) { function createClaudeHistoryAdapter() { return { id: 'claude-history', + description: 'Claude local session history and session-file snapshots', + targetTypes: ['claude-history', 'claude-alias', 'session-file'], canOpen(target, context = {}) { if (context.adapterId && context.adapterId !== 'claude-history') { return false; diff --git a/scripts/lib/session-adapters/dmux-tmux.js b/scripts/lib/session-adapters/dmux-tmux.js index 43b5be95..05fffa1f 100644 --- a/scripts/lib/session-adapters/dmux-tmux.js +++ b/scripts/lib/session-adapters/dmux-tmux.js @@ -45,6 +45,8 @@ function createDmuxTmuxAdapter(options = {}) { return { id: 'dmux-tmux', + description: 'Tmux/worktree orchestration snapshots from plan files or session names', + targetTypes: ['plan', 'session'], canOpen(target, context = {}) { if (context.adapterId && context.adapterId !== 'dmux-tmux') { return false; diff --git a/scripts/lib/session-adapters/registry.js b/scripts/lib/session-adapters/registry.js index d3a2fc4f..cdbf24f7 100644 --- a/scripts/lib/session-adapters/registry.js +++ b/scripts/lib/session-adapters/registry.js @@ -3,6 +3,14 @@ const { createClaudeHistoryAdapter } = require('./claude-history'); const { createDmuxTmuxAdapter } = require('./dmux-tmux'); +const TARGET_TYPE_TO_ADAPTER_ID = Object.freeze({ + plan: 'dmux-tmux', + session: 'dmux-tmux', + 'claude-history': 'claude-history', + 'claude-alias': 'claude-history', + 'session-file': 'claude-history' +}); + function createDefaultAdapters() { return [ createClaudeHistoryAdapter(), @@ -10,13 +18,72 @@ function createDefaultAdapters() { ]; } +function coerceTargetValue(value) { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error('Structured session targets require a non-empty string value'); + } + + return value.trim(); +} + +function normalizeStructuredTarget(target, context = {}) { + if (!target || typeof target !== 'object' || Array.isArray(target)) { + return { + target, + context: { ...context } + }; + } + + const value = coerceTargetValue(target.value); + const type = typeof target.type === 'string' ? target.type.trim() : ''; + if (type.length === 0) { + throw new Error('Structured session targets require a non-empty type'); + } + + const adapterId = target.adapterId || TARGET_TYPE_TO_ADAPTER_ID[type] || context.adapterId || null; + const nextContext = { + ...context, + adapterId + }; + + if (type === 'claude-history' || type === 'claude-alias') { + return { + target: `claude:${value}`, + context: nextContext + }; + } + + return { + target: value, + context: nextContext + }; +} + function createAdapterRegistry(options = {}) { const adapters = options.adapters || createDefaultAdapters(); return { adapters, + getAdapter(id) { + const adapter = adapters.find(candidate => candidate.id === id); + if (!adapter) { + throw new Error(`Unknown session adapter: ${id}`); + } + + return adapter; + }, + listAdapters() { + return adapters.map(adapter => ({ + id: adapter.id, + description: adapter.description || '', + targetTypes: Array.isArray(adapter.targetTypes) ? [...adapter.targetTypes] : [] + })); + }, select(target, context = {}) { - const adapter = adapters.find(candidate => candidate.canOpen(target, context)); + const normalized = normalizeStructuredTarget(target, context); + const adapter = normalized.context.adapterId + ? this.getAdapter(normalized.context.adapterId) + : adapters.find(candidate => candidate.canOpen(normalized.target, normalized.context)); if (!adapter) { throw new Error(`No session adapter matched target: ${target}`); } @@ -24,8 +91,9 @@ function createAdapterRegistry(options = {}) { return adapter; }, open(target, context = {}) { - const adapter = this.select(target, context); - return adapter.open(target, context); + const normalized = normalizeStructuredTarget(target, context); + const adapter = this.select(normalized.target, normalized.context); + return adapter.open(normalized.target, normalized.context); } }; } @@ -38,5 +106,6 @@ function inspectSessionTarget(target, options = {}) { module.exports = { createAdapterRegistry, createDefaultAdapters, - inspectSessionTarget + inspectSessionTarget, + normalizeStructuredTarget }; diff --git a/scripts/session-inspect.js b/scripts/session-inspect.js index 9e658998..c0e7684f 100644 --- a/scripts/session-inspect.js +++ b/scripts/session-inspect.js @@ -4,12 +4,13 @@ const fs = require('fs'); const path = require('path'); -const { inspectSessionTarget } = require('./lib/session-adapters/registry'); +const { createAdapterRegistry, inspectSessionTarget } = require('./lib/session-adapters/registry'); function usage() { console.log([ 'Usage:', - ' node scripts/session-inspect.js [--adapter ] [--write ]', + ' node scripts/session-inspect.js [--adapter ] [--target-type ] [--write ]', + ' node scripts/session-inspect.js --list-adapters', '', 'Targets:', ' Dmux/orchestration plan file', @@ -22,6 +23,7 @@ function usage() { ' node scripts/session-inspect.js .claude/plan/workflow.json', ' node scripts/session-inspect.js workflow-visual-proof', ' node scripts/session-inspect.js claude:latest', + ' node scripts/session-inspect.js latest --target-type claude-history', ' node scripts/session-inspect.js claude:a1b2c3d4 --write /tmp/session.json' ].join('\n')); } @@ -29,25 +31,36 @@ function usage() { function parseArgs(argv) { const args = argv.slice(2); const target = args.find(argument => !argument.startsWith('--')); + const listAdapters = args.includes('--list-adapters'); const adapterIndex = args.indexOf('--adapter'); const adapterId = adapterIndex >= 0 ? args[adapterIndex + 1] : null; + const targetTypeIndex = args.indexOf('--target-type'); + const targetType = targetTypeIndex >= 0 ? args[targetTypeIndex + 1] : null; + const writeIndex = args.indexOf('--write'); const writePath = writeIndex >= 0 ? args[writeIndex + 1] : null; - return { target, adapterId, writePath }; + return { target, adapterId, targetType, writePath, listAdapters }; } function main() { - const { target, adapterId, writePath } = parseArgs(process.argv); + const { target, adapterId, targetType, writePath, listAdapters } = parseArgs(process.argv); + + if (listAdapters) { + const registry = createAdapterRegistry(); + console.log(JSON.stringify({ adapters: registry.listAdapters() }, null, 2)); + return; + } if (!target) { usage(); process.exit(1); } - const snapshot = inspectSessionTarget(target, { + const inspectTarget = targetType ? { type: targetType, value: target } : target; + const snapshot = inspectSessionTarget(inspectTarget, { cwd: process.cwd(), adapterId }); diff --git a/tests/lib/session-adapters.test.js b/tests/lib/session-adapters.test.js index 64f7e4e2..e1234d3d 100644 --- a/tests/lib/session-adapters.test.js +++ b/tests/lib/session-adapters.test.js @@ -210,5 +210,76 @@ test('adapter registry routes plan files to dmux and explicit claude targets to } }); +test('adapter registry resolves structured target types into the correct adapter', () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-typed-repo-')); + const planPath = path.join(repoRoot, 'workflow.json'); + fs.writeFileSync(planPath, JSON.stringify({ + sessionName: 'workflow-typed-proof', + repoRoot, + coordinationRoot: path.join(repoRoot, '.claude', 'orchestration') + })); + + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-typed-home-')); + const sessionsDir = path.join(homeDir, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.writeFileSync( + path.join(sessionsDir, '2026-03-13-z9y8x7w6-session.tmp'), + '# Typed History Session\n\n**Branch:** feat/typed-targets\n' + ); + + try { + withHome(homeDir, () => { + const registry = createAdapterRegistry({ + adapters: [ + createDmuxTmuxAdapter({ + collectSessionSnapshotImpl: () => ({ + sessionName: 'workflow-typed-proof', + coordinationDir: path.join(repoRoot, '.claude', 'orchestration', 'workflow-typed-proof'), + repoRoot, + targetType: 'plan', + sessionActive: true, + paneCount: 0, + workerCount: 0, + workerStates: {}, + panes: [], + workers: [] + }) + }), + createClaudeHistoryAdapter() + ] + }); + + const dmuxSnapshot = registry.open({ type: 'plan', value: planPath }, { cwd: repoRoot }).getSnapshot(); + const claudeSnapshot = registry.open({ type: 'claude-history', value: 'latest' }, { cwd: repoRoot }).getSnapshot(); + + assert.strictEqual(dmuxSnapshot.adapterId, 'dmux-tmux'); + assert.strictEqual(dmuxSnapshot.session.sourceTarget.type, 'plan'); + assert.strictEqual(claudeSnapshot.adapterId, 'claude-history'); + assert.strictEqual(claudeSnapshot.session.sourceTarget.type, 'claude-history'); + assert.strictEqual(claudeSnapshot.workers[0].branch, 'feat/typed-targets'); + }); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); + } +}); + +test('adapter registry lists adapter metadata and target types', () => { + const registry = createAdapterRegistry(); + const adapters = registry.listAdapters(); + const ids = adapters.map(adapter => adapter.id); + + assert.ok(ids.includes('claude-history')); + assert.ok(ids.includes('dmux-tmux')); + assert.ok( + adapters.some(adapter => adapter.id === 'claude-history' && adapter.targetTypes.includes('claude-history')), + 'claude-history should advertise its canonical target type' + ); + assert.ok( + adapters.some(adapter => adapter.id === 'dmux-tmux' && adapter.targetTypes.includes('plan')), + 'dmux-tmux should advertise plan targets' + ); +}); + console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); if (failed > 0) process.exit(1); diff --git a/tests/scripts/session-inspect.test.js b/tests/scripts/session-inspect.test.js index 5f5b0465..cb6cb30d 100644 --- a/tests/scripts/session-inspect.test.js +++ b/tests/scripts/session-inspect.test.js @@ -56,6 +56,15 @@ function runTests() { assert.ok(result.stdout.includes('Usage:')); })) passed++; else failed++; + if (test('lists registered adapters', () => { + const result = run(['--list-adapters']); + assert.strictEqual(result.code, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.ok(Array.isArray(payload.adapters)); + assert.ok(payload.adapters.some(adapter => adapter.id === 'claude-history')); + assert.ok(payload.adapters.some(adapter => adapter.id === 'dmux-tmux')); + })) passed++; else failed++; + if (test('prints canonical JSON for claude history targets', () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-home-')); const sessionsDir = path.join(homeDir, '.claude', 'sessions'); @@ -81,6 +90,31 @@ function runTests() { } })) passed++; else failed++; + if (test('supports explicit target types for structured registry routing', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-home-')); + const sessionsDir = path.join(homeDir, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + try { + fs.writeFileSync( + path.join(sessionsDir, '2026-03-13-a1b2c3d4-session.tmp'), + '# Inspect Session\n\n**Branch:** feat/typed-inspect\n' + ); + + const result = run(['latest', '--target-type', 'claude-history'], { + env: { HOME: homeDir } + }); + + assert.strictEqual(result.code, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.strictEqual(payload.adapterId, 'claude-history'); + assert.strictEqual(payload.session.sourceTarget.type, 'claude-history'); + assert.strictEqual(payload.workers[0].branch, 'feat/typed-inspect'); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + if (test('writes snapshot JSON to disk when --write is provided', () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-home-')); const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-inspect-out-'));