From 2d9e6977dc6e998f96028dcadf9c79f3fa345c03 Mon Sep 17 00:00:00 2001 From: calm Date: Sat, 10 Oct 2026 20:11:00 +0800 Subject: [PATCH] feat: add native Qoder plugin support --- .qoder-plugin/README.md | 40 +++++++ .qoder-plugin/plugin.json | 27 +++++ README.md | 28 ++++- agents/harness-optimizer.md | 2 +- hooks/qoder-hooks.json | 22 ++++ package.json | 1 + scripts/hooks/plugin-hook-bootstrap.js | 2 +- scripts/release.sh | 6 +- tests/plugin-manifest.test.js | 27 +++++ tests/qoder-plugin.test.js | 126 ++++++++++++++++++++++ tests/scripts/npm-publish-surface.test.js | 2 + 11 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 .qoder-plugin/README.md create mode 100644 .qoder-plugin/plugin.json create mode 100644 hooks/qoder-hooks.json create mode 100644 tests/qoder-plugin.test.js diff --git a/.qoder-plugin/README.md b/.qoder-plugin/README.md new file mode 100644 index 000000000..fa2ed9224 --- /dev/null +++ b/.qoder-plugin/README.md @@ -0,0 +1,40 @@ +# ECC for Qoder + +This directory contains ECC's native Qoder plugin manifest. The repository root +is the plugin bundle: Qoder loads the canonical `skills/`, `agents/`, and +`commands/` directories rather than copies maintained under `.qoder-plugin/`. + +## Install and verify + +ECC's hook runtime requires Node.js 18 or newer on Qoder's non-interactive +`PATH`. From a reviewed checkout: + +```bash +qoder plugins validate . --strict +qoder plugins install . +qoder plugins list +``` + +For Qoder IDE, create a ZIP whose root contains `.qoder-plugin/`, `skills/`, +`agents/`, `commands/`, `hooks/`, `scripts/`, and `.mcp.json`, then use +**Upload Plugin**. Do not upload only `.qoder-plugin/` or an individual +`SKILL.md`; both would omit referenced resources. + +## Capability boundary + +- Qoder discovers all compatible skills and agents from their canonical root + directories. `commands/` remains a legacy compatibility surface; workflows + should prefer their corresponding skills. +- The bundled `chrome-devtools` MCP server uses the same pinned `npx` launch + contract as Codex. It is downloaded on first use and therefore requires Node, + npm/npx, and network access. +- Qoder receives only ECC's reviewed `SessionStart` bootstrap. The much broader + Claude hook profile is not copied: blocking tool hooks, asynchronous stop + jobs, and Claude-specific matchers have not been asserted to be Qoder-safe. +- The Qoder hook uses `command` plus `args`, `QODER_PLUGIN_ROOT`, and Node's own + executable for child processes. It does not rely on Bash, `python3`, or shell + variable expansion, so the launcher contract is portable across native + Windows, macOS, and Linux when Node 18+ is available. + +Qoder plugin installation is separate from ECC's managed installer. Do not +combine it with a manually copied Qoder skill installation. diff --git a/.qoder-plugin/plugin.json b/.qoder-plugin/plugin.json new file mode 100644 index 000000000..c76c97f9b --- /dev/null +++ b/.qoder-plugin/plugin.json @@ -0,0 +1,27 @@ +{ + "name": "ecc", + "displayName": "ECC", + "version": "2.2.3", + "description": "Harness-native engineering workflows for Qoder: 302 skills, 71 agents, legacy command shims, a reviewed SessionStart hook, and optional MCP tooling.", + "author": { + "name": "Affaan Mustafa", + "url": "https://x.com/affaanmustafa" + }, + "homepage": "https://ecc.tools", + "repository": "https://github.com/affaan-m/ECC", + "license": "MIT", + "keywords": [ + "qoder", + "agents", + "skills", + "tdd", + "code-review", + "security", + "workflow", + "automation" + ], + "skills": "./skills/", + "commands": "./commands/", + "mcpServers": "./.mcp.json", + "hooks": "./hooks/qoder-hooks.json" +} diff --git a/README.md b/README.md index 67f4bf63b..507b2fb3b 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ Instead of rebuilding that process in every prompt, you install it once and make > Optimize the context window. Persist everything else. -ECC is MIT-licensed open source. It works best with Claude Code today, has a supported Codex sync path, and provides capability-limited adapters for Cursor, OpenCode, Gemini, Zed, GitHub Copilot, Antigravity, Qwen, and other harnesses. See the [support status matrix](#platform-support) before assuming feature parity. +ECC is MIT-licensed open source. It works best with Claude Code today, has native Codex and Qoder plugin paths, and provides capability-limited adapters for Cursor, OpenCode, Gemini, Zed, GitHub Copilot, Antigravity, Qwen, and other harnesses. See the [support status matrix](#platform-support) before assuming feature parity. Access to 71 agents, 302 skills, and 95 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work. @@ -383,6 +383,31 @@ You can also open the ECC repository directly in Codex for a project-local setup For repo navigation, surface ownership, and PR diff packet guidance, read the [Codex ECC Navigation Map](docs/CODEX-NAVIGATION-GUIDE.md). See the [.codex plugin notes](.codex-plugin/README.md) for native lifecycle details. +### Qoder CLI and IDE + +ECC ships a native Qoder plugin that reuses the canonical 302 skills, 71 agents, +95 legacy command shims, and the pinned `chrome-devtools` MCP definition. From a +reviewed checkout, validate and install it with Qoder CLI: + +```bash +qoder plugins validate . --strict +qoder plugins install . +qoder plugins list +``` + +For Qoder IDE, upload a ZIP of the repository root. The ZIP root must contain +`.qoder-plugin/` together with `skills/`, `agents/`, `commands/`, `hooks/`, +`scripts/`, and `.mcp.json`; uploading only the manifest directory or one +`SKILL.md` omits required resources. + +The universal Node.js 18+ requirement applies to Qoder's non-interactive hook +environment. The MCP server also requires `npx` and network access on first use. +Qoder receives one reviewed, cross-platform `SessionStart` bootstrap using +`QODER_PLUGIN_ROOT` and exec-form arguments. ECC does not copy the full Claude +hook profile into Qoder: blocking tool hooks, async stop jobs, and +Claude-specific matchers remain outside this initial native integration. See +the [.qoder-plugin notes](.qoder-plugin/README.md) for the exact boundary. + ### Other agents and editors
@@ -1292,6 +1317,7 @@ Treat `stable`, `beta`, `experimental`, and `instruction-only` below as capabili |---|---|---|---| | Claude Code | Stable primary | Plugin or selective installer | The plugin advertises the installed catalog to the model; use a selective/manual profile when context footprint matters. Optional shell-backed skills are not portable to every OS. | | Codex | Supported native plugin | Codex marketplace plugin or repo config | Native hooks require an explicit trust decision and do not use Claude's hook profiles. The legacy sync is compatibility-only. | +| Qoder | Supported native plugin | Local plugin directory in Qoder CLI or ZIP upload in Qoder IDE | Loads the canonical skills, agents, commands, and MCP config; only the reviewed SessionStart hook is enabled, and Node.js 18+ must be on the non-interactive PATH. | | Cursor | Beta project adapter | Selective installer into `.cursor/` | Agent discovery varies by Cursor build; hook events use profile-gated dispatchers, but full Claude feature parity is not claimed. | | OpenCode | Beta built plugin | Build plugin, then selective installer | ECC ships a subset of the catalog; connect a provider and select a model in OpenCode ([#2617](https://github.com/affaan-m/ECC/issues/2617)). | | GitHub Copilot Chat | Instruction-only | Checked-in instructions and prompt files | ECC hooks are not adapted. | diff --git a/agents/harness-optimizer.md b/agents/harness-optimizer.md index 7bc8b2c5d..cdb09e31d 100644 --- a/agents/harness-optimizer.md +++ b/agents/harness-optimizer.md @@ -3,7 +3,7 @@ name: harness-optimizer description: Improve local agent-harness configuration reliability and cost using eval-driven grading (pass@k/pass^k) derived from the eval-harness skill. tools: Read, Grep, Glob, Bash, Edit model: sonnet -color: teal +color: cyan --- ## Prompt Defense Baseline diff --git a/hooks/qoder-hooks.json b/hooks/qoder-hooks.json new file mode 100644 index 000000000..95662affb --- /dev/null +++ b/hooks/qoder-hooks.json @@ -0,0 +1,22 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact|new", + "hooks": [ + { + "type": "command", + "command": "node", + "args": [ + "${QODER_PLUGIN_ROOT}/scripts/hooks/plugin-hook-bootstrap.js", + "node", + "scripts/hooks/session-start-bootstrap.js" + ], + "timeout": 30, + "statusMessage": "Loading ECC session context" + } + ] + } + ] + } +} diff --git a/package.json b/package.json index 38c067438..4b50ae0a7 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ ".claude-plugin/", ".codex/", ".codex-plugin/", + ".qoder-plugin/", ".cursor/", ".gemini/", ".github/PULL_REQUEST_TEMPLATE.md", diff --git a/scripts/hooks/plugin-hook-bootstrap.js b/scripts/hooks/plugin-hook-bootstrap.js index ddcc14118..e8a2746b9 100644 --- a/scripts/hooks/plugin-hook-bootstrap.js +++ b/scripts/hooks/plugin-hook-bootstrap.js @@ -262,7 +262,7 @@ async function main() { contextScanner.push(contextDecoder.end()); const hookContext = contextScanner.context; const rootDir = normalizePluginRootForPlatform( - process.env.CLAUDE_PLUGIN_ROOT || process.env.ECC_PLUGIN_ROOT + process.env.QODER_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || process.env.ECC_PLUGIN_ROOT ); if (!mode || !relPath || !rootDir) { diff --git a/scripts/release.sh b/scripts/release.sh index 21e9e4311..26af3e7cf 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -16,6 +16,7 @@ PLUGIN_JSON=".claude-plugin/plugin.json" MARKETPLACE_JSON=".claude-plugin/marketplace.json" CODEX_MARKETPLACE_JSON=".agents/plugins/marketplace.json" CODEX_PLUGIN_JSON=".codex-plugin/plugin.json" +QODER_PLUGIN_JSON=".qoder-plugin/plugin.json" CODEX_MARKETPLACE_PLUGIN_JSON="plugins/ecc/.codex-plugin/plugin.json" OPENCODE_PACKAGE_JSON=".opencode/package.json" OPENCODE_PACKAGE_LOCK_JSON=".opencode/package-lock.json" @@ -60,7 +61,7 @@ if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; then fi # Verify versioned manifests exist -for FILE in "$ROOT_PACKAGE_JSON" "$PACKAGE_LOCK_JSON" "$ROOT_AGENTS_MD" "$TR_AGENTS_MD" "$ZH_CN_AGENTS_MD" "$AGENT_YAML" "$VERSION_FILE" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$CODEX_MARKETPLACE_JSON" "$CODEX_PLUGIN_JSON" "$OPENCODE_PACKAGE_JSON" "$OPENCODE_PACKAGE_LOCK_JSON" "$OPENCODE_ECC_HOOKS_PLUGIN" "$README_FILE" "$ROOT_ZH_CN_README_FILE" "$TR_README_FILE" "$PT_BR_README_FILE" "$ZH_CN_README_FILE" "$SELECTIVE_INSTALL_ARCHITECTURE_DOC"; do +for FILE in "$ROOT_PACKAGE_JSON" "$PACKAGE_LOCK_JSON" "$ROOT_AGENTS_MD" "$TR_AGENTS_MD" "$ZH_CN_AGENTS_MD" "$AGENT_YAML" "$VERSION_FILE" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$CODEX_MARKETPLACE_JSON" "$CODEX_PLUGIN_JSON" "$QODER_PLUGIN_JSON" "$OPENCODE_PACKAGE_JSON" "$OPENCODE_PACKAGE_LOCK_JSON" "$OPENCODE_ECC_HOOKS_PLUGIN" "$README_FILE" "$ROOT_ZH_CN_README_FILE" "$TR_README_FILE" "$PT_BR_README_FILE" "$ZH_CN_README_FILE" "$SELECTIVE_INSTALL_ARCHITECTURE_DOC"; do if [[ ! -f "$FILE" ]]; then echo "Error: $FILE not found" exit 1 @@ -306,6 +307,7 @@ update_version "$PLUGIN_JSON" "s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSIO update_marketplace_plugin_version "$MARKETPLACE_JSON" update_codex_marketplace_version update_version "$CODEX_PLUGIN_JSON" "s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|" +update_version "$QODER_PLUGIN_JSON" "s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|" update_version "$CODEX_MARKETPLACE_PLUGIN_JSON" "s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|" update_version "$OPENCODE_PACKAGE_JSON" "s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|" update_package_lock_version "$OPENCODE_PACKAGE_LOCK_JSON" @@ -330,7 +332,7 @@ node tests/plugin-manifest.test.js # Stage, commit, explicitly sign an annotated tag, and push. Signing failure # stops here under set -e; no personal tag.gpgSign default is assumed. -git add "$ROOT_PACKAGE_JSON" "$PACKAGE_LOCK_JSON" "$ROOT_AGENTS_MD" "$TR_AGENTS_MD" "$ZH_CN_AGENTS_MD" "$AGENT_YAML" "$VERSION_FILE" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$CODEX_MARKETPLACE_JSON" "$CODEX_PLUGIN_JSON" "$CODEX_MARKETPLACE_PLUGIN_JSON" "$OPENCODE_PACKAGE_JSON" "$OPENCODE_PACKAGE_LOCK_JSON" "$OPENCODE_ECC_HOOKS_PLUGIN" "$README_FILE" "$ROOT_ZH_CN_README_FILE" "$TR_README_FILE" "$PT_BR_README_FILE" "$ZH_CN_README_FILE" "$SELECTIVE_INSTALL_ARCHITECTURE_DOC" +git add "$ROOT_PACKAGE_JSON" "$PACKAGE_LOCK_JSON" "$ROOT_AGENTS_MD" "$TR_AGENTS_MD" "$ZH_CN_AGENTS_MD" "$AGENT_YAML" "$VERSION_FILE" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$CODEX_MARKETPLACE_JSON" "$CODEX_PLUGIN_JSON" "$QODER_PLUGIN_JSON" "$CODEX_MARKETPLACE_PLUGIN_JSON" "$OPENCODE_PACKAGE_JSON" "$OPENCODE_PACKAGE_LOCK_JSON" "$OPENCODE_ECC_HOOKS_PLUGIN" "$README_FILE" "$ROOT_ZH_CN_README_FILE" "$TR_README_FILE" "$PT_BR_README_FILE" "$ZH_CN_README_FILE" "$SELECTIVE_INSTALL_ARCHITECTURE_DOC" git commit -m "chore: bump plugin version to $VERSION" git tag -s "v$VERSION" -m "Release v$VERSION" git push origin main "v$VERSION" diff --git a/tests/plugin-manifest.test.js b/tests/plugin-manifest.test.js index 74bd25ec4..be7a39194 100644 --- a/tests/plugin-manifest.test.js +++ b/tests/plugin-manifest.test.js @@ -461,6 +461,33 @@ test('codex plugin presentation assets exist and ship in npm package', () => { } }); +// ── Qoder plugin manifest ───────────────────────────────────────────────────── +console.log('\n=== .qoder-plugin/plugin.json ===\n'); + +const qoderPluginPath = path.join(repoRoot, '.qoder-plugin', 'plugin.json'); +const qoderPlugin = loadJsonObject(qoderPluginPath, '.qoder-plugin/plugin.json'); + +test('qoder plugin.json version matches package.json', () => { + assert.strictEqual(qoderPlugin.name, 'ecc'); + assert.strictEqual(qoderPlugin.version, expectedVersion); +}); + +test('qoder plugin references canonical resources inside the bundle', () => { + for (const field of ['skills', 'commands', 'mcpServers', 'hooks']) { + assert.strictEqual(typeof qoderPlugin[field], 'string', `Expected Qoder manifest ${field} path`); + const target = path.resolve(repoRoot, qoderPlugin[field]); + assert.ok(target === repoRoot || target.startsWith(repoRoot + path.sep), `${field} escapes the Qoder bundle`); + assert.ok(fs.existsSync(target), `${field} target is missing: ${qoderPlugin[field]}`); + } +}); + +test('qoder native surface ships in npm package', () => { + const packageFiles = new Set(rootPackage.files); + assert.ok(packageFiles.has('.qoder-plugin/')); + assert.ok(packageFiles.has('hooks/')); + assert.ok(packageFiles.has('scripts/hooks/')); +}); + // ── .mcp.json at plugin root ────────────────────────────────────────────────── // Per official docs: keep .mcp.json at plugin root, NOT inside .codex-plugin/ console.log('\n=== .mcp.json (plugin root) ===\n'); diff --git a/tests/qoder-plugin.test.js b/tests/qoder-plugin.test.js new file mode 100644 index 000000000..ba7e98511 --- /dev/null +++ b/tests/qoder-plugin.test.js @@ -0,0 +1,126 @@ +/** + * Qoder native plugin contract tests. + * + * These tests intentionally execute the declared hook entry point instead of + * only inspecting JSON. Qoder launches hooks without a shell when `command` + * and `args` are separate, which keeps the same contract on Windows, macOS, + * and Linux. + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const repoRoot = path.resolve(__dirname, '..'); +const manifestPath = path.join(repoRoot, '.qoder-plugin', 'plugin.json'); +const hooksPath = path.join(repoRoot, 'hooks', 'qoder-hooks.json'); +const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); +const releaseScript = fs.readFileSync(path.join(repoRoot, 'scripts', 'release.sh'), 'utf8'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed += 1; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + failed += 1; + } +} + +console.log('\n=== Qoder native plugin ===\n'); + +test('manifest and hook projection exist', () => { + assert.ok(fs.existsSync(manifestPath), 'Expected .qoder-plugin/plugin.json'); + assert.ok(fs.existsSync(hooksPath), 'Expected hooks/qoder-hooks.json'); +}); + +if (fs.existsSync(manifestPath) && fs.existsSync(hooksPath)) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); + + test('manifest stays versioned with the npm package', () => { + assert.strictEqual(manifest.name, 'ecc'); + assert.strictEqual(manifest.version, packageJson.version); + }); + + test('release tooling updates and stages the Qoder manifest', () => { + assert.ok(releaseScript.includes('QODER_PLUGIN_JSON=".qoder-plugin/plugin.json"')); + assert.ok(releaseScript.includes('update_version "$QODER_PLUGIN_JSON"')); + assert.ok(releaseScript.includes('git add ') && releaseScript.includes('"$QODER_PLUGIN_JSON"')); + }); + + test('manifest reuses canonical plugin resources', () => { + assert.strictEqual(manifest.skills, './skills/'); + assert.strictEqual(manifest.commands, './commands/'); + assert.strictEqual(manifest.mcpServers, './.mcp.json'); + assert.strictEqual(manifest.hooks, './hooks/qoder-hooks.json'); + }); + + test('agent colors are accepted by Qoder', () => { + const allowedColors = new Set([ + 'red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan', + ]); + for (const fileName of fs.readdirSync(path.join(repoRoot, 'agents'))) { + if (!fileName.endsWith('.md')) continue; + const source = fs.readFileSync(path.join(repoRoot, 'agents', fileName), 'utf8'); + const match = source.match(/^color:\s*(\S+)$/m); + if (match) { + assert.ok(allowedColors.has(match[1]), `${fileName} uses unsupported Qoder color ${match[1]}`); + } + } + }); + + test('Qoder hook is a minimal SessionStart projection using exec form', () => { + assert.deepStrictEqual(Object.keys(hooks.hooks), ['SessionStart']); + const registrations = hooks.hooks.SessionStart; + assert.strictEqual(registrations.length, 1); + assert.strictEqual(registrations[0].matcher, 'startup|resume|clear|compact|new'); + assert.strictEqual(registrations[0].hooks.length, 1); + const hook = registrations[0].hooks[0]; + assert.strictEqual(hook.type, 'command'); + assert.strictEqual(hook.command, 'node'); + assert.ok(Array.isArray(hook.args)); + assert.strictEqual(hook.args[0], '${QODER_PLUGIN_ROOT}/scripts/hooks/plugin-hook-bootstrap.js'); + assert.deepStrictEqual(hook.args.slice(1), [ + 'node', + 'scripts/hooks/session-start-bootstrap.js', + ]); + }); + + test('declared Qoder SessionStart launcher executes from QODER_PLUGIN_ROOT', () => { + const hook = hooks.hooks.SessionStart[0].hooks[0]; + const args = hook.args.map(value => value.replace('${QODER_PLUGIN_ROOT}', repoRoot)); + const result = spawnSync(hook.command, args, { + cwd: repoRoot, + env: { + ...process.env, + QODER_PLUGIN_ROOT: repoRoot, + ECC_HOOKS_ENABLED: 'false', + }, + input: JSON.stringify({ + hook_event_name: 'SessionStart', + source: 'startup', + cwd: repoRoot, + }), + encoding: 'utf8', + timeout: 10000, + windowsHide: true, + }); + + assert.ifError(result.error); + assert.strictEqual(result.signal, null); + assert.strictEqual(result.status, 0, result.stderr); + }); +} + +console.log(`\nPassed: ${passed}`); +console.log(`Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index b911cb9d2..c72e78c57 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -113,6 +113,7 @@ function buildExpectedPublishPaths(repoRoot) { "scripts/codex/merge-codex-config.js", "scripts/codex/merge-mcp-config.js", ".codex-plugin", + ".qoder-plugin", "plugins/ecc", ".mcp.json", "install.sh", @@ -280,6 +281,7 @@ function main() { ".claude-plugin/plugin.json", ".github/PULL_REQUEST_TEMPLATE.md", ".codex-plugin/plugin.json", + ".qoder-plugin/plugin.json", ".agents/skills/unified-memory/SKILL.md", ".agents/skills/unified-memory/agents/openai.yaml", ".cursor/skills/unified-memory/SKILL.md",