fix: context-size /compact trigger, Codex marketplace plugin path, live README badges (#2237)

- suggest-compact hook now reads the latest usage record from the session
  transcript and suggests /compact at a window-scaled token threshold
  (160k/200k window, 250k/1M window; COMPACT_CONTEXT_THRESHOLD and
  COMPACT_CONTEXT_INTERVAL overridable), re-firing per 60k-token growth
  bucket; tool-call count stays as the secondary signal (#2155)
- Codex repo marketplace now points at ./plugins/ecc instead of ./ — Codex
  never discovers plugins whose local marketplace source.path is the
  marketplace root (verified on Codex CLI 0.137.0); plugins/ecc is a thin
  folder referencing root skills/.mcp.json per maintainer direction on
  #2097; docs flag plugin mode as experimental with the upstream blocker
  openai/codex#26037 linked (#2128)
- README badges for installs/stars/forks now use shields endpoint badges
  backed by api.ecc.tools (live install count 3,712 vs the stale static
  150), which also eliminates shields' 'Unable to select next GitHub token
  from pool' render in the stars badge

Closes #2155
Closes #2128
This commit is contained in:
Affaan Mustafa
2026-06-11 16:21:53 -04:00
committed by GitHub
parent fec84fcf19
commit 7777656bf5
23 changed files with 1098 additions and 96 deletions

View File

@@ -33,10 +33,18 @@ function test(name, fn) {
* Returns { code, stdout, stderr }.
*/
function runCompact(envOverrides = {}) {
return runCompactWithInput('{}', envOverrides);
}
/**
* Run suggest-compact.js with a custom stdin payload (hook input JSON).
* Returns { code, stdout, stderr }.
*/
function runCompactWithInput(input, envOverrides = {}) {
const env = { ...process.env, ...envOverrides };
const result = spawnSync('node', [compactScript], {
encoding: 'utf8',
input: '{}',
input: typeof input === 'string' ? input : JSON.stringify(input),
timeout: 10000,
env,
});
@@ -637,6 +645,252 @@ function runTests() {
})) passed++;
else failed++;
// ── Context-size trigger (#2155) ──
// Tool count is a weak proxy for window pressure. The hook now also reads
// the latest `usage` record from the session transcript (transcript_path in
// the hook stdin payload) and suggests /compact at a window-scaled token
// threshold, re-firing only after another interval of context growth.
console.log('\nContext-size trigger (#2155):');
function getBucketFilePath(sessionId) {
return path.join(os.tmpdir(), `claude-context-bucket-${sessionId}`);
}
let transcriptSeq = 0;
function writeTranscriptFixture(tokens, model = 'claude-sonnet-4-6') {
transcriptSeq += 1;
const filePath = path.join(os.tmpdir(), `compact-transcript-${process.pid}-${transcriptSeq}.jsonl`);
writeTranscriptTokens(filePath, tokens, model);
return filePath;
}
function writeTranscriptTokens(filePath, tokens, model = 'claude-sonnet-4-6') {
const record = JSON.stringify({
type: 'assistant',
message: {
model,
usage: {
input_tokens: tokens,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
output_tokens: 50
}
}
});
fs.writeFileSync(filePath, record + '\n');
}
function createContextContext() {
const base = createCounterContext('test-context');
const bucketFile = getBucketFilePath(base.sessionId);
return {
...base,
bucketFile,
cleanup() {
base.cleanup();
try { fs.unlinkSync(bucketFile); } catch (_err) { /* ignore */ }
}
};
}
if (test('suggests compact when context exceeds the 200k-window threshold', () => {
const ctx = createContextContext();
const transcript = writeTranscriptFixture(170000);
try {
const result = runCompactWithInput({ session_id: ctx.sessionId, transcript_path: transcript });
assert.strictEqual(result.code, 0, 'Should exit 0');
assert.ok(result.stdout.trim().length > 0, `Expected stdout payload. Got: "${result.stdout}"`);
const parsed = JSON.parse(result.stdout);
const context = parsed.hookSpecificOutput.additionalContext;
assert.ok(context.includes('Context ~170k tokens'), `Expected token estimate. Got: ${context}`);
assert.ok(context.includes('85% of 200k window'), `Expected window percentage. Got: ${context}`);
} finally {
try { fs.unlinkSync(transcript); } catch (_err) { /* ignore */ }
ctx.cleanup();
}
})) passed++;
else failed++;
if (test('stays silent below the context threshold', () => {
const ctx = createContextContext();
const transcript = writeTranscriptFixture(100000);
try {
const result = runCompactWithInput({ session_id: ctx.sessionId, transcript_path: transcript });
assert.strictEqual(result.code, 0);
assert.strictEqual(result.stdout.trim(), '', `Expected silent run below threshold. Got: "${result.stdout}"`);
} finally {
try { fs.unlinkSync(transcript); } catch (_err) { /* ignore */ }
ctx.cleanup();
}
})) passed++;
else failed++;
if (test('honours COMPACT_CONTEXT_THRESHOLD override', () => {
const ctx = createContextContext();
const transcript = writeTranscriptFixture(1500);
try {
const result = runCompactWithInput(
{ session_id: ctx.sessionId, transcript_path: transcript },
{ COMPACT_CONTEXT_THRESHOLD: '1000' }
);
assert.ok(result.stdout.includes('Context ~2k tokens'), `Expected context suggestion with overridden threshold. Got: "${result.stdout}"`);
} finally {
try { fs.unlinkSync(transcript); } catch (_err) { /* ignore */ }
ctx.cleanup();
}
})) passed++;
else failed++;
if (test('does not re-fire within the same context bucket', () => {
const ctx = createContextContext();
const transcript = writeTranscriptFixture(170000);
try {
const first = runCompactWithInput({ session_id: ctx.sessionId, transcript_path: transcript });
assert.ok(first.stdout.includes('Context ~170k tokens'), 'First run should fire');
const second = runCompactWithInput({ session_id: ctx.sessionId, transcript_path: transcript });
assert.strictEqual(second.stdout.trim(), '', `Second run in the same bucket must be silent. Got: "${second.stdout}"`);
} finally {
try { fs.unlinkSync(transcript); } catch (_err) { /* ignore */ }
ctx.cleanup();
}
})) passed++;
else failed++;
if (test('re-fires after the context grows by another interval', () => {
const ctx = createContextContext();
const transcript = writeTranscriptFixture(170000);
try {
runCompactWithInput({ session_id: ctx.sessionId, transcript_path: transcript });
// Default interval is 60k: 160k threshold + 60k => next bucket at 220k.
writeTranscriptTokens(transcript, 230000, 'claude-sonnet-4-6[1m]');
const result = runCompactWithInput(
{ session_id: ctx.sessionId, transcript_path: transcript },
// Pin the threshold so window detection (230k > 200k => 1M window,
// 250k default threshold) does not silence the growth re-fire.
{ COMPACT_CONTEXT_THRESHOLD: '160000' }
);
assert.ok(result.stdout.includes('Context ~230k tokens'), `Expected re-fire after interval growth. Got: "${result.stdout}"`);
} finally {
try { fs.unlinkSync(transcript); } catch (_err) { /* ignore */ }
ctx.cleanup();
}
})) passed++;
else failed++;
if (test('uses the 250k default threshold for [1m] models', () => {
const ctx = createContextContext();
const transcript = writeTranscriptFixture(230000, 'claude-opus-4-5[1m]');
try {
const silent = runCompactWithInput({ session_id: ctx.sessionId, transcript_path: transcript });
assert.strictEqual(silent.stdout.trim(), '', `230k on a 1M window must stay silent. Got: "${silent.stdout}"`);
writeTranscriptTokens(transcript, 260000, 'claude-opus-4-5[1m]');
const fired = runCompactWithInput({ session_id: ctx.sessionId, transcript_path: transcript });
assert.ok(fired.stdout.includes('26% of 1M window'), `260k on a 1M window should fire. Got: "${fired.stdout}"`);
} finally {
try { fs.unlinkSync(transcript); } catch (_err) { /* ignore */ }
ctx.cleanup();
}
})) passed++;
else failed++;
if (test('treats >200k observed tokens as a 1M window even without the [1m] marker', () => {
const ctx = createContextContext();
const transcript = writeTranscriptFixture(230000, 'claude-opus-4-5');
try {
const result = runCompactWithInput({ session_id: ctx.sessionId, transcript_path: transcript });
// 230k would exceed the 160k standard threshold, but the observed size
// implies a 1M window whose 250k default threshold is not reached yet.
assert.strictEqual(result.stdout.trim(), '', `Expected 1M-window inference to keep run silent. Got: "${result.stdout}"`);
} finally {
try { fs.unlinkSync(transcript); } catch (_err) { /* ignore */ }
ctx.cleanup();
}
})) passed++;
else failed++;
if (test('COMPACT_CONTEXT_THRESHOLD=0 disables the context signal', () => {
const ctx = createContextContext();
const transcript = writeTranscriptFixture(170000);
try {
const result = runCompactWithInput(
{ session_id: ctx.sessionId, transcript_path: transcript },
{ COMPACT_CONTEXT_THRESHOLD: '0' }
);
assert.strictEqual(result.stdout.trim(), '', `Disabled signal must stay silent. Got: "${result.stdout}"`);
} finally {
try { fs.unlinkSync(transcript); } catch (_err) { /* ignore */ }
ctx.cleanup();
}
})) passed++;
else failed++;
if (test('survives a malformed transcript (exit 0, silent)', () => {
const ctx = createContextContext();
const transcript = path.join(os.tmpdir(), `compact-transcript-broken-${Date.now()}.jsonl`);
fs.writeFileSync(transcript, 'this is not json\n{broken');
try {
const result = runCompactWithInput({ session_id: ctx.sessionId, transcript_path: transcript });
assert.strictEqual(result.code, 0, 'Must exit 0 on malformed transcript');
assert.strictEqual(result.stdout.trim(), '');
} finally {
try { fs.unlinkSync(transcript); } catch (_err) { /* ignore */ }
ctx.cleanup();
}
})) passed++;
else failed++;
if (test('survives a missing transcript path (exit 0, count signal intact)', () => {
const ctx = createContextContext();
try {
fs.writeFileSync(ctx.counterFile, '49');
const result = runCompactWithInput({
session_id: ctx.sessionId,
transcript_path: path.join(os.tmpdir(), `missing-${Date.now()}.jsonl`)
});
assert.strictEqual(result.code, 0);
assert.ok(result.stdout.includes('50 tool calls reached'), `Count signal must still work. Got: "${result.stdout}"`);
} finally {
ctx.cleanup();
}
})) passed++;
else failed++;
if (test('emits a single stdout JSON payload when both signals fire', () => {
const ctx = createContextContext();
const transcript = writeTranscriptFixture(170000);
try {
fs.writeFileSync(ctx.counterFile, '49');
const result = runCompactWithInput({ session_id: ctx.sessionId, transcript_path: transcript });
const lines = result.stdout.trim().split('\n');
assert.strictEqual(lines.length, 1, `Hook must emit exactly one stdout JSON line. Got: "${result.stdout}"`);
const parsed = JSON.parse(lines[0]);
const context = parsed.hookSpecificOutput.additionalContext;
assert.ok(context.includes('Context ~170k tokens'), `Expected context signal. Got: ${context}`);
assert.ok(context.includes('50 tool calls reached'), `Expected count signal. Got: ${context}`);
} finally {
try { fs.unlinkSync(transcript); } catch (_err) { /* ignore */ }
ctx.cleanup();
}
})) passed++;
else failed++;
if (test('sweeps stale context bucket state files', () => {
const ctx = createContextContext();
const stale = getBucketFilePath(`stale-bucket-${Date.now()}`);
fs.writeFileSync(stale, '2');
setMtimeDaysAgo(stale, 30);
try {
const result = runCompact({ CLAUDE_SESSION_ID: ctx.sessionId });
assert.strictEqual(result.code, 0);
assert.ok(!fs.existsSync(stale), `Stale bucket state file should have been swept. Path: ${stale}`);
} finally {
try { fs.unlinkSync(stale); } catch (_err) { /* ignore */ }
ctx.cleanup();
}
})) passed++;
else failed++;
// Summary
console.log(`
Results: Passed: ${passed}, Failed: ${failed}`);

View File

@@ -0,0 +1,262 @@
'use strict';
/**
* Tests for scripts/lib/transcript-context.js (#2155)
*
* Covers transcript usage extraction, context-window detection, threshold and
* interval resolution, and the bucket math the strategic-compact hook uses.
*
* Run with: node tests/lib/transcript-context.test.js
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
STANDARD_CONTEXT_WINDOW_TOKENS,
LARGE_CONTEXT_WINDOW_TOKENS,
DEFAULT_CONTEXT_THRESHOLD_STANDARD,
DEFAULT_CONTEXT_THRESHOLD_LARGE,
DEFAULT_CONTEXT_INTERVAL_TOKENS,
readLatestContextTokens,
resolveContextWindowTokens,
resolveContextThreshold,
resolveContextInterval,
computeContextBucket,
formatWindowLabel
} = require('../../scripts/lib/transcript-context');
console.log('=== Testing transcript-context.js ===\n');
let passed = 0;
let failed = 0;
function test(desc, fn) {
try {
fn();
console.log(`${desc}`);
passed++;
} catch (e) {
console.log(`${desc}: ${e.message}`);
failed++;
}
}
let fixtureSeq = 0;
function writeTranscript(lines) {
fixtureSeq += 1;
const filePath = path.join(os.tmpdir(), `transcript-context-test-${process.pid}-${fixtureSeq}.jsonl`);
fs.writeFileSync(filePath, lines.join('\n') + '\n');
return filePath;
}
function usageRecord(tokens, model = 'claude-sonnet-4-6', extra = {}) {
return JSON.stringify({
type: 'assistant',
message: {
model,
usage: {
input_tokens: tokens.input || 0,
cache_read_input_tokens: tokens.cacheRead || 0,
cache_creation_input_tokens: tokens.cacheCreation || 0,
output_tokens: tokens.output || 0
}
},
...extra
});
}
const cleanupPaths = [];
function tracked(filePath) {
cleanupPaths.push(filePath);
return filePath;
}
// ── readLatestContextTokens ──
console.log('readLatestContextTokens:');
test('sums input + cache_read + cache_creation from the latest usage record', () => {
const file = tracked(writeTranscript([
usageRecord({ input: 10, cacheRead: 20, cacheCreation: 5 }),
usageRecord({ input: 100, cacheRead: 150000, cacheCreation: 7000 })
]));
const result = readLatestContextTokens(file);
assert.ok(result, 'Expected a usage result');
assert.strictEqual(result.tokens, 157100);
});
test('returns the model id alongside the token count', () => {
const file = tracked(writeTranscript([
usageRecord({ input: 1000 }, 'claude-opus-4-5[1m]')
]));
const result = readLatestContextTokens(file);
assert.strictEqual(result.model, 'claude-opus-4-5[1m]');
});
test('skips trailing records without usage (e.g. tool results)', () => {
const file = tracked(writeTranscript([
usageRecord({ input: 5000 }),
JSON.stringify({ type: 'user', message: { content: 'tool result' } }),
JSON.stringify({ type: 'system', subtype: 'info' })
]));
const result = readLatestContextTokens(file);
assert.strictEqual(result.tokens, 5000);
});
test('skips malformed JSONL lines without throwing', () => {
const file = tracked(writeTranscript([
usageRecord({ input: 4200 }),
'{not json at all',
''
]));
const result = readLatestContextTokens(file);
assert.strictEqual(result.tokens, 4200);
});
test('returns null for a transcript with no usage records', () => {
const file = tracked(writeTranscript([
JSON.stringify({ type: 'user', message: { content: 'hello' } })
]));
assert.strictEqual(readLatestContextTokens(file), null);
});
test('returns null for a missing transcript file', () => {
assert.strictEqual(readLatestContextTokens(path.join(os.tmpdir(), 'definitely-missing.jsonl')), null);
});
test('returns null for empty or non-string paths', () => {
assert.strictEqual(readLatestContextTokens(''), null);
assert.strictEqual(readLatestContextTokens(undefined), null);
});
test('ignores zero-token usage records', () => {
const file = tracked(writeTranscript([
usageRecord({ input: 999 }),
usageRecord({ input: 0 })
]));
const result = readLatestContextTokens(file);
assert.strictEqual(result.tokens, 999);
});
test('only scans the transcript tail (latest records win on large files)', () => {
const filler = JSON.stringify({ type: 'system', note: 'x'.repeat(512) });
const lines = [usageRecord({ input: 11 })];
for (let i = 0; i < 50; i++) lines.push(filler);
lines.push(usageRecord({ input: 170000 }));
const file = tracked(writeTranscript(lines));
// Tail window smaller than the file forces the truncated-tail path.
const result = readLatestContextTokens(file, { tailBytes: 4096 });
assert.strictEqual(result.tokens, 170000);
});
// ── resolveContextWindowTokens ──
console.log('\nresolveContextWindowTokens:');
test('defaults to the standard 200k window', () => {
assert.strictEqual(resolveContextWindowTokens(50000, 'claude-sonnet-4-6'), STANDARD_CONTEXT_WINDOW_TOKENS);
});
test('detects a 1M window from the [1m] model marker', () => {
assert.strictEqual(resolveContextWindowTokens(50000, 'claude-opus-4-5[1m]'), LARGE_CONTEXT_WINDOW_TOKENS);
});
test('detects a 1M window when observed tokens exceed 200k (marker dropped)', () => {
assert.strictEqual(resolveContextWindowTokens(220000, 'claude-opus-4-5'), LARGE_CONTEXT_WINDOW_TOKENS);
});
test('treats an empty model id as standard window', () => {
assert.strictEqual(resolveContextWindowTokens(100000, ''), STANDARD_CONTEXT_WINDOW_TOKENS);
});
// ── resolveContextThreshold ──
console.log('\nresolveContextThreshold:');
test('defaults to 160k for the 200k window', () => {
assert.strictEqual(resolveContextThreshold({}, STANDARD_CONTEXT_WINDOW_TOKENS), DEFAULT_CONTEXT_THRESHOLD_STANDARD);
});
test('defaults to 250k for the 1M window', () => {
assert.strictEqual(resolveContextThreshold({}, LARGE_CONTEXT_WINDOW_TOKENS), DEFAULT_CONTEXT_THRESHOLD_LARGE);
});
test('honours COMPACT_CONTEXT_THRESHOLD override', () => {
assert.strictEqual(resolveContextThreshold({ COMPACT_CONTEXT_THRESHOLD: '1234' }, STANDARD_CONTEXT_WINDOW_TOKENS), 1234);
});
test('COMPACT_CONTEXT_THRESHOLD=0 disables the signal', () => {
assert.strictEqual(resolveContextThreshold({ COMPACT_CONTEXT_THRESHOLD: '0' }, STANDARD_CONTEXT_WINDOW_TOKENS), 0);
});
test('invalid COMPACT_CONTEXT_THRESHOLD falls back to the default', () => {
for (const bad of ['-5', 'abc', '99999999999']) {
assert.strictEqual(
resolveContextThreshold({ COMPACT_CONTEXT_THRESHOLD: bad }, STANDARD_CONTEXT_WINDOW_TOKENS),
DEFAULT_CONTEXT_THRESHOLD_STANDARD,
`Expected fallback for ${bad}`
);
}
});
// ── resolveContextInterval ──
console.log('\nresolveContextInterval:');
test('defaults to 60k tokens', () => {
assert.strictEqual(resolveContextInterval({}), DEFAULT_CONTEXT_INTERVAL_TOKENS);
});
test('honours COMPACT_CONTEXT_INTERVAL override', () => {
assert.strictEqual(resolveContextInterval({ COMPACT_CONTEXT_INTERVAL: '5000' }), 5000);
});
test('invalid COMPACT_CONTEXT_INTERVAL falls back to the default', () => {
for (const bad of ['0', '-1', 'abc']) {
assert.strictEqual(resolveContextInterval({ COMPACT_CONTEXT_INTERVAL: bad }), DEFAULT_CONTEXT_INTERVAL_TOKENS, `Expected fallback for ${bad}`);
}
});
// ── computeContextBucket ──
console.log('\ncomputeContextBucket:');
test('returns -1 below the threshold', () => {
assert.strictEqual(computeContextBucket(159999, 160000, 60000), -1);
});
test('returns bucket 0 at the threshold', () => {
assert.strictEqual(computeContextBucket(160000, 160000, 60000), 0);
});
test('increments the bucket after each interval of growth', () => {
assert.strictEqual(computeContextBucket(219999, 160000, 60000), 0);
assert.strictEqual(computeContextBucket(220000, 160000, 60000), 1);
assert.strictEqual(computeContextBucket(280000, 160000, 60000), 2);
});
test('returns -1 when the threshold is disabled (0)', () => {
assert.strictEqual(computeContextBucket(500000, 0, 60000), -1);
});
test('returns -1 for non-finite token counts', () => {
assert.strictEqual(computeContextBucket(NaN, 160000, 60000), -1);
});
// ── formatWindowLabel ──
console.log('\nformatWindowLabel:');
test('labels the standard and large windows', () => {
assert.strictEqual(formatWindowLabel(STANDARD_CONTEXT_WINDOW_TOKENS), '200k');
assert.strictEqual(formatWindowLabel(LARGE_CONTEXT_WINDOW_TOKENS), '1M');
});
// Cleanup
for (const filePath of cleanupPaths) {
try {
fs.unlinkSync(filePath);
} catch {
/* ignore */
}
}
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);

View File

@@ -393,7 +393,11 @@ test('marketplace.json plugin version matches package.json', () => {
assert.strictEqual(marketplace.plugins[0].version, expectedVersion);
});
test('marketplace local plugin path resolves to the repo-root Codex bundle', () => {
test('marketplace local plugin path resolves to a concrete plugin subdirectory (#2128)', () => {
// Codex does not discover plugins whose local marketplace source.path is the
// marketplace root itself ("./") — verified against Codex CLI 0.137.0 and
// the official docs ($REPO_ROOT/plugins/<name>). The entry must point at a
// real plugin folder strictly inside the repo.
for (const plugin of marketplace.plugins) {
if (!plugin.source || plugin.source.source !== 'local') {
continue;
@@ -401,12 +405,67 @@ test('marketplace local plugin path resolves to the repo-root Codex bundle', ()
assert.ok(plugin.source.path.startsWith('./'), `Codex marketplace source.path must be ./-prefixed: ${plugin.source.path}`);
const resolvedRoot = path.resolve(repoRoot, plugin.source.path);
assert.strictEqual(resolvedRoot, repoRoot, `Expected local marketplace path to resolve to repo root from marketplace root, got: ${plugin.source.path}`);
assert.ok(fs.existsSync(path.join(resolvedRoot, '.codex-plugin', 'plugin.json')), `Codex plugin manifest missing under resolved marketplace root: ${plugin.source.path}`);
assert.ok(fs.existsSync(path.join(resolvedRoot, '.mcp.json')), `Root MCP config missing under resolved marketplace root: ${plugin.source.path}`);
assert.notStrictEqual(resolvedRoot, repoRoot, `Codex never discovers "./" marketplace roots — source.path must target a plugin subdirectory (#2128), got: ${plugin.source.path}`);
assert.ok(resolvedRoot.startsWith(repoRoot + path.sep), `Expected local marketplace path to stay inside the repo, got: ${plugin.source.path}`);
assert.ok(fs.existsSync(path.join(resolvedRoot, '.codex-plugin', 'plugin.json')), `Codex plugin manifest missing under resolved plugin folder: ${plugin.source.path}`);
}
});
// ── plugins/ecc marketplace plugin folder ─────────────────────────────────────
// Thin Codex plugin target for the repo marketplace. Content is single-sourced
// at the repo root (no vendored skills/MCP copies) per the maintainer direction
// on #2097; these tests pin the manifest sync and the parent-relative refs.
console.log('\n=== plugins/ecc Codex marketplace plugin folder ===\n');
const marketplacePluginManifestPath = path.join(repoRoot, 'plugins', 'ecc', '.codex-plugin', 'plugin.json');
const marketplacePluginManifest = loadJsonObject(marketplacePluginManifestPath, 'plugins/ecc/.codex-plugin/plugin.json');
const rootCodexManifest = loadJsonObject(path.join(repoRoot, '.codex-plugin', 'plugin.json'), '.codex-plugin/plugin.json');
test('plugins/ecc manifest name matches the root Codex manifest', () => {
assert.strictEqual(marketplacePluginManifest.name, rootCodexManifest.name);
});
test('plugins/ecc manifest version matches package.json', () => {
assert.strictEqual(marketplacePluginManifest.version, expectedVersion);
});
test('plugins/ecc manifest version matches the root Codex manifest', () => {
assert.strictEqual(marketplacePluginManifest.version, rootCodexManifest.version);
});
test('plugins/ecc manifest reuses root skills and MCP config without vendoring', () => {
const pluginDir = path.dirname(path.dirname(marketplacePluginManifestPath));
const skillsTarget = path.resolve(pluginDir, marketplacePluginManifest.skills);
assert.strictEqual(skillsTarget, path.join(repoRoot, 'skills'), `skills ref must resolve to the root skills/ directory, got: ${marketplacePluginManifest.skills}`);
assert.ok(fs.existsSync(skillsTarget), 'Root skills/ directory missing');
const mcpTarget = path.resolve(pluginDir, marketplacePluginManifest.mcpServers);
assert.strictEqual(mcpTarget, path.join(repoRoot, '.mcp.json'), `mcpServers ref must resolve to the root .mcp.json, got: ${marketplacePluginManifest.mcpServers}`);
assert.ok(fs.existsSync(mcpTarget), 'Root .mcp.json missing');
assert.ok(!fs.existsSync(path.join(pluginDir, 'skills')), 'plugins/ecc must not vendor a second skills/ copy (see #2097 review)');
assert.ok(!fs.existsSync(path.join(pluginDir, '.mcp.json')), 'plugins/ecc must not vendor a second .mcp.json (see #2097 review)');
});
test('plugins/ecc manifest interface assets resolve to root assets', () => {
const pluginDir = path.dirname(path.dirname(marketplacePluginManifestPath));
for (const ref of [marketplacePluginManifest.interface.composerIcon, marketplacePluginManifest.interface.logo]) {
const target = path.resolve(pluginDir, ref);
assert.ok(target.startsWith(path.join(repoRoot, 'assets') + path.sep), `Asset ref must resolve under root assets/: ${ref}`);
assert.ok(fs.existsSync(target), `Asset ref target missing: ${ref}`);
}
});
test('plugins/ecc README documents the upstream Codex fragility', () => {
const readmePath = path.join(repoRoot, 'plugins', 'ecc', 'README.md');
assert.ok(fs.existsSync(readmePath), 'Expected plugins/ecc/README.md');
const source = fs.readFileSync(readmePath, 'utf8');
assert.ok(source.includes('openai/codex'), 'plugins/ecc README must link the upstream Codex discovery issue');
assert.ok(source.includes('sync-ecc-to-codex.sh'), 'plugins/ecc README must point at the supported manual sync flow');
});
test('.opencode/package.json version matches package.json', () => {
assert.strictEqual(opencodePackage.version, expectedVersion);
});

View File

@@ -71,6 +71,7 @@ function buildExpectedPublishPaths(repoRoot) {
"scripts/codex/merge-codex-config.js",
"scripts/codex/merge-mcp-config.js",
".codex-plugin",
"plugins/ecc",
".mcp.json",
"install.sh",
"install.ps1",
@@ -143,6 +144,7 @@ function main() {
".qwen/QWEN.md",
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
"plugins/ecc/.codex-plugin/plugin.json",
"assets/ecc-icon.svg",
"assets/hero.png",
"schemas/install-state.schema.json",