From 7777656bf5c2c1b6971409bb0d28cb95d822dab9 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 11 Jun 2026 16:21:53 -0400 Subject: [PATCH] fix: context-size /compact trigger, Codex marketplace plugin path, live README badges (#2237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .agents/plugins/marketplace.json | 2 +- .agents/skills/strategic-compact/SKILL.md | 9 +- .codex-plugin/README.md | 20 +- README.md | 17 +- README.zh-CN.md | 2 +- docs/de-DE/README.md | 6 +- docs/es/README.md | 6 +- docs/ko-KR/README.md | 2 +- docs/pt-BR/README.md | 2 +- docs/ru/README.md | 2 +- docs/ur/README.md | 6 +- docs/zh-CN/README.md | 2 +- package.json | 1 + plugins/ecc/.codex-plugin/plugin.json | 36 +++ plugins/ecc/README.md | 41 ++++ scripts/hooks/suggest-compact.js | 213 +++++++++++++----- scripts/lib/transcript-context.js | 226 +++++++++++++++++++ scripts/release.sh | 2 + skills/strategic-compact/SKILL.md | 12 +- tests/hooks/suggest-compact.test.js | 256 ++++++++++++++++++++- tests/lib/transcript-context.test.js | 262 ++++++++++++++++++++++ tests/plugin-manifest.test.js | 67 +++++- tests/scripts/npm-publish-surface.test.js | 2 + 23 files changed, 1098 insertions(+), 96 deletions(-) create mode 100644 plugins/ecc/.codex-plugin/plugin.json create mode 100644 plugins/ecc/README.md create mode 100644 scripts/lib/transcript-context.js create mode 100644 tests/lib/transcript-context.test.js diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 6168f2e4..0c459b60 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -9,7 +9,7 @@ "version": "2.0.0", "source": { "source": "local", - "path": "./" + "path": "./plugins/ecc" }, "policy": { "installation": "AVAILABLE", diff --git a/.agents/skills/strategic-compact/SKILL.md b/.agents/skills/strategic-compact/SKILL.md index 2e37f40a..33261c0a 100644 --- a/.agents/skills/strategic-compact/SKILL.md +++ b/.agents/skills/strategic-compact/SKILL.md @@ -29,11 +29,10 @@ Strategic compaction at logical boundaries: ## How It Works -The `suggest-compact.js` script runs on PreToolUse (Edit/Write) and: +The `suggest-compact.js` script runs on PreToolUse (Edit/Write) and combines two signals: -1. **Tracks tool calls** — Counts tool invocations in session -2. **Threshold detection** — Suggests at configurable threshold (default: 50 calls) -3. **Periodic reminders** — Reminds every 25 calls after threshold +1. **Context size (primary)** — Reads the latest `usage` record from the session transcript (`transcript_path` in the hook payload) and sums `input_tokens + cache_read_input_tokens + cache_creation_input_tokens` (the true context size of the turn). Suggests `/compact` at a window-scaled threshold — 160k tokens on a 200k window, 250k on a 1M window (detected from a `[1m]` model marker, or inferred when observed tokens already exceed 200k) — and re-reminds after every additional 60k tokens of context growth +2. **Tool-call count (secondary)** — Counts tool invocations in session; suggests at a configurable threshold (default: 50 calls), then every 25 calls after ## Hook Setup @@ -60,6 +59,8 @@ Add to your `~/.claude/settings.json`: Environment variables: - `COMPACT_THRESHOLD` — Tool calls before first suggestion (default: 50) +- `COMPACT_CONTEXT_THRESHOLD` — Context tokens before the context-size suggestion (default: 160000 on a 200k window, 250000 on a 1M window; `0` disables the context signal) +- `COMPACT_CONTEXT_INTERVAL` — Additional context tokens before the suggestion repeats (default: 60000) ## Compaction Decision Guide diff --git a/.codex-plugin/README.md b/.codex-plugin/README.md index af7afac7..7d4c5a19 100644 --- a/.codex-plugin/README.md +++ b/.codex-plugin/README.md @@ -30,10 +30,22 @@ codex plugin marketplace add affaan-m/ECC codex plugin marketplace add /absolute/path/to/ECC ``` -The marketplace entry points at the repository root so `.codex-plugin/plugin.json`, -`skills/`, and `.mcp.json` resolve from one shared source of truth. After adding -or updating the marketplace, restart Codex and install or enable `ecc` from the -plugin directory. +The marketplace entry points at `plugins/ecc/` — Codex does not discover +plugins whose local marketplace `source.path` is the marketplace root (`./`), +so the entry must target a concrete plugin subdirectory (see +[#2128](https://github.com/affaan-m/ECC/issues/2128)). That thin plugin folder +references the root `skills/` and `.mcp.json` so content stays single-sourced. +After adding or updating the marketplace, restart Codex and install or enable +`ecc` from the plugin directory. + +> **Plugin mode is currently fragile on Codex.** Marketplace discovery and +> install work with this layout, but runtime skill loading from local/repo +> marketplaces is unreliable upstream +> ([openai/codex#26037](https://github.com/openai/codex/issues/26037)) — Codex +> copies only the plugin folder into its install cache, so parent-referenced +> content may not be exposed in a fresh session. The safer, fully supported +> path today is the manual sync flow: +> `npm install && bash scripts/sync-ecc-to-codex.sh`. Official Plugin Directory publishing is coming soon. For official OpenAI plugin-directory review, package this repo under the `openai/plugins` diff --git a/README.md b/README.md index cb3ad85a..7b2d7f7d 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ ![ECC — the agent harness operating system](assets/hero.png) -[![Stars](https://img.shields.io/github/stars/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/stargazers) -[![Forks](https://img.shields.io/github/forks/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/network/members) +[![Stars](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Fstars&style=flat)](https://github.com/affaan-m/ECC/stargazers) +[![Forks](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Fforks&style=flat)](https://github.com/affaan-m/ECC/network/members) [![Contributors](https://img.shields.io/github/contributors/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/graphs/contributors) [![npm ecc-universal](https://img.shields.io/npm/dw/ecc-universal?label=ecc-universal%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-universal) [![npm ecc-agentshield](https://img.shields.io/npm/dw/ecc-agentshield?label=ecc-agentshield%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-agentshield) -[![GitHub App Install](https://img.shields.io/badge/GitHub%20App-150%20installs-2ea44f?logo=github)](https://github.com/marketplace/ecc-tools) +[![GitHub App Install](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Finstalls&logo=github)](https://github.com/marketplace/ecc-tools) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ![Shell](https://img.shields.io/badge/-Shell-4EAA25?logo=gnu-bash&logoColor=white) ![TypeScript](https://img.shields.io/badge/-TypeScript-3178C6?logo=typescript&logoColor=white) @@ -1384,6 +1384,17 @@ Codex macOS app: - The reference `.codex/config.toml` intentionally does not pin `model` or `model_provider`, so Codex uses its own current default unless you override it. - Optional: copy `.codex/config.toml` to `~/.codex/config.toml` for global defaults; keep the multi-agent role files project-local unless you also copy `.codex/agents/`. +### Codex Plugin Marketplace (experimental) + +The repo also exposes a Codex repo-scoped marketplace (`.agents/plugins/marketplace.json`) whose entry points at the `plugins/ecc/` plugin folder — Codex does not discover plugins whose local marketplace `source.path` is the repository root (`./`), so the entry must target a concrete plugin subdirectory: + +```bash +codex plugin marketplace add affaan-m/ECC +codex plugin list # ecc@ecc should appear +``` + +**Plugin mode is currently fragile on Codex.** Marketplace discovery and install work with this layout, but runtime skill loading from local/repo marketplaces is still unreliable upstream ([openai/codex#26037](https://github.com/openai/codex/issues/26037)): Codex copies only the plugin folder into its install cache, so plugins that reference shared repo content may not expose skills in a fresh session. Until that settles, treat the plugin path as experimental and prefer the manual sync flow above (`scripts/sync-ecc-to-codex.sh`), which is the supported Codex route. See [#2128](https://github.com/affaan-m/ECC/issues/2128) for the full investigation. + ### What's Included | Component | Count | Details | diff --git a/README.zh-CN.md b/README.zh-CN.md index 3e29edcf..e9608d6a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,7 +5,7 @@ [![Contributors](https://img.shields.io/github/contributors/affaan-m/everything-claude-code?style=flat)](https://github.com/affaan-m/everything-claude-code/graphs/contributors) [![npm ecc-universal](https://img.shields.io/npm/dw/ecc-universal?label=ecc-universal%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-universal) [![npm ecc-agentshield](https://img.shields.io/npm/dw/ecc-agentshield?label=ecc-agentshield%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-agentshield) -[![GitHub App Install](https://img.shields.io/badge/GitHub%20App-150%20installs-2ea44f?logo=github)](https://github.com/marketplace/ecc-tools) +[![GitHub App Install](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Finstalls&logo=github)](https://github.com/marketplace/ecc-tools) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ![Shell](https://img.shields.io/badge/-Shell-4EAA25?logo=gnu-bash&logoColor=white) ![TypeScript](https://img.shields.io/badge/-TypeScript-3178C6?logo=typescript&logoColor=white) diff --git a/docs/de-DE/README.md b/docs/de-DE/README.md index c42203fa..c7463d5f 100644 --- a/docs/de-DE/README.md +++ b/docs/de-DE/README.md @@ -4,12 +4,12 @@ ![ECC - das Harness-native Operator-System für agentische Arbeit](../../assets/hero.png) -[![Stars](https://img.shields.io/github/stars/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/stargazers) -[![Forks](https://img.shields.io/github/forks/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/network/members) +[![Stars](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Fstars&style=flat)](https://github.com/affaan-m/ECC/stargazers) +[![Forks](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Fforks&style=flat)](https://github.com/affaan-m/ECC/network/members) [![Contributors](https://img.shields.io/github/contributors/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/graphs/contributors) [![npm ecc-universal](https://img.shields.io/npm/dw/ecc-universal?label=ecc-universal%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-universal) [![npm ecc-agentshield](https://img.shields.io/npm/dw/ecc-agentshield?label=ecc-agentshield%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-agentshield) -[![GitHub App Install](https://img.shields.io/badge/GitHub%20App-150%20installs-2ea44f?logo=github)](https://github.com/marketplace/ecc-tools) +[![GitHub App Install](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Finstalls&logo=github)](https://github.com/marketplace/ecc-tools) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE) ![Shell](https://img.shields.io/badge/-Shell-4EAA25?logo=gnu-bash&logoColor=white) ![TypeScript](https://img.shields.io/badge/-TypeScript-3178C6?logo=typescript&logoColor=white) diff --git a/docs/es/README.md b/docs/es/README.md index 3acf87a3..d0e105a2 100644 --- a/docs/es/README.md +++ b/docs/es/README.md @@ -4,12 +4,12 @@ ![ECC - el sistema operativo nativo del harness para trabajo agentivo](../../assets/hero.png) -[![Stars](https://img.shields.io/github/stars/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/stargazers) -[![Forks](https://img.shields.io/github/forks/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/network/members) +[![Stars](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Fstars&style=flat)](https://github.com/affaan-m/ECC/stargazers) +[![Forks](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Fforks&style=flat)](https://github.com/affaan-m/ECC/network/members) [![Contributors](https://img.shields.io/github/contributors/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/graphs/contributors) [![npm ecc-universal](https://img.shields.io/npm/dw/ecc-universal?label=ecc-universal%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-universal) [![npm ecc-agentshield](https://img.shields.io/npm/dw/ecc-agentshield?label=ecc-agentshield%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-agentshield) -[![GitHub App Install](https://img.shields.io/badge/GitHub%20App-150%20installs-2ea44f?logo=github)](https://github.com/marketplace/ecc-tools) +[![GitHub App Install](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Finstalls&logo=github)](https://github.com/marketplace/ecc-tools) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ![Shell](https://img.shields.io/badge/-Shell-4EAA25?logo=gnu-bash&logoColor=white) ![TypeScript](https://img.shields.io/badge/-TypeScript-3178C6?logo=typescript&logoColor=white) diff --git a/docs/ko-KR/README.md b/docs/ko-KR/README.md index e2891a8f..66726f98 100644 --- a/docs/ko-KR/README.md +++ b/docs/ko-KR/README.md @@ -7,7 +7,7 @@ [![Contributors](https://img.shields.io/github/contributors/affaan-m/everything-claude-code?style=flat)](https://github.com/affaan-m/everything-claude-code/graphs/contributors) [![npm ecc-universal](https://img.shields.io/npm/dw/ecc-universal?label=ecc-universal%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-universal) [![npm ecc-agentshield](https://img.shields.io/npm/dw/ecc-agentshield?label=ecc-agentshield%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-agentshield) -[![GitHub App Install](https://img.shields.io/badge/GitHub%20App-150%20installs-2ea44f?logo=github)](https://github.com/marketplace/ecc-tools) +[![GitHub App Install](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Finstalls&logo=github)](https://github.com/marketplace/ecc-tools) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE) ![Shell](https://img.shields.io/badge/-Shell-4EAA25?logo=gnu-bash&logoColor=white) ![TypeScript](https://img.shields.io/badge/-TypeScript-3178C6?logo=typescript&logoColor=white) diff --git a/docs/pt-BR/README.md b/docs/pt-BR/README.md index cccfb79c..c78fe77f 100644 --- a/docs/pt-BR/README.md +++ b/docs/pt-BR/README.md @@ -7,7 +7,7 @@ [![Contributors](https://img.shields.io/github/contributors/affaan-m/everything-claude-code?style=flat)](https://github.com/affaan-m/everything-claude-code/graphs/contributors) [![npm ecc-universal](https://img.shields.io/npm/dw/ecc-universal?label=ecc-universal%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-universal) [![npm ecc-agentshield](https://img.shields.io/npm/dw/ecc-agentshield?label=ecc-agentshield%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-agentshield) -[![GitHub App Install](https://img.shields.io/badge/GitHub%20App-150%20installs-2ea44f?logo=github)](https://github.com/marketplace/ecc-tools) +[![GitHub App Install](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Finstalls&logo=github)](https://github.com/marketplace/ecc-tools) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE) ![Shell](https://img.shields.io/badge/-Shell-4EAA25?logo=gnu-bash&logoColor=white) ![TypeScript](https://img.shields.io/badge/-TypeScript-3178C6?logo=typescript&logoColor=white) diff --git a/docs/ru/README.md b/docs/ru/README.md index 36bd65b4..f42a19c9 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -9,7 +9,7 @@ [![Contributors](https://img.shields.io/github/contributors/affaan-m/everything-claude-code?style=flat)](https://github.com/affaan-m/everything-claude-code/graphs/contributors) [![npm ecc-universal](https://img.shields.io/npm/dw/ecc-universal?label=ecc-universal%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-universal) [![npm ecc-agentshield](https://img.shields.io/npm/dw/ecc-agentshield?label=ecc-agentshield%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-agentshield) -[![GitHub App Install](https://img.shields.io/badge/GitHub%20App-150%20installs-2ea44f?logo=github)](https://github.com/marketplace/ecc-tools) +[![GitHub App Install](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Finstalls&logo=github)](https://github.com/marketplace/ecc-tools) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE) ![Shell](https://img.shields.io/badge/-Shell-4EAA25?logo=gnu-bash&logoColor=white) ![TypeScript](https://img.shields.io/badge/-TypeScript-3178C6?logo=typescript&logoColor=white) diff --git a/docs/ur/README.md b/docs/ur/README.md index 64e78003..7981c2a5 100644 --- a/docs/ur/README.md +++ b/docs/ur/README.md @@ -4,12 +4,12 @@ ![ECC - ایجنٹک کام کے لیے ہارنس-نیٹو آپریٹر سسٹم](../../assets/hero.png) -[![Stars](https://img.shields.io/github/stars/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/stargazers) -[![Forks](https://img.shields.io/github/forks/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/network/members) +[![Stars](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Fstars&style=flat)](https://github.com/affaan-m/ECC/stargazers) +[![Forks](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Fforks&style=flat)](https://github.com/affaan-m/ECC/network/members) [![Contributors](https://img.shields.io/github/contributors/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/graphs/contributors) [![npm ecc-universal](https://img.shields.io/npm/dw/ecc-universal?label=ecc-universal%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-universal) [![npm ecc-agentshield](https://img.shields.io/npm/dw/ecc-agentshield?label=ecc-agentshield%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-agentshield) -[![GitHub App Install](https://img.shields.io/badge/GitHub%20App-150%20installs-2ea44f?logo=github)](https://github.com/marketplace/ecc-tools) +[![GitHub App Install](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Finstalls&logo=github)](https://github.com/marketplace/ecc-tools) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE) ![Shell](https://img.shields.io/badge/-Shell-4EAA25?logo=gnu-bash&logoColor=white) ![TypeScript](https://img.shields.io/badge/-TypeScript-3178C6?logo=typescript&logoColor=white) diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 49303d97..0be8a146 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -7,7 +7,7 @@ [![Contributors](https://img.shields.io/github/contributors/affaan-m/everything-claude-code?style=flat)](https://github.com/affaan-m/everything-claude-code/graphs/contributors) [![npm ecc-universal](https://img.shields.io/npm/dw/ecc-universal?label=ecc-universal%20weekly%20downloads\&logo=npm)](https://www.npmjs.com/package/ecc-universal) [![npm ecc-agentshield](https://img.shields.io/npm/dw/ecc-agentshield?label=ecc-agentshield%20weekly%20downloads\&logo=npm)](https://www.npmjs.com/package/ecc-agentshield) -[![GitHub App Install](https://img.shields.io/badge/GitHub%20App-150%20installs-2ea44f?logo=github)](https://github.com/marketplace/ecc-tools) +[![GitHub App Install](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Finstalls&logo=github)](https://github.com/marketplace/ecc-tools) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ![Shell](https://img.shields.io/badge/-Shell-4EAA25?logo=gnu-bash\&logoColor=white) ![TypeScript](https://img.shields.io/badge/-TypeScript-3178C6?logo=typescript\&logoColor=white) diff --git a/package.json b/package.json index 2cf5b7a9..6998ead8 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "install.sh", "manifests/", "mcp-configs/", + "plugins/ecc/", "rules/", "schemas/", "scripts/catalog.js", diff --git a/plugins/ecc/.codex-plugin/plugin.json b/plugins/ecc/.codex-plugin/plugin.json new file mode 100644 index 00000000..f515ee56 --- /dev/null +++ b/plugins/ecc/.codex-plugin/plugin.json @@ -0,0 +1,36 @@ +{ + "name": "ecc", + "version": "2.0.0", + "description": "Harness-native ECC workflows for Codex: shared skills, production-ready MCP configs, and selective-install-aligned conventions for TDD, security scanning, code review, and autonomous development.", + "author": { + "name": "Affaan Mustafa", + "email": "me@affaanmustafa.com", + "url": "https://x.com/affaanmustafa" + }, + "homepage": "https://ecc.tools", + "repository": "https://github.com/affaan-m/ECC", + "license": "MIT", + "keywords": ["codex", "agents", "skills", "tdd", "code-review", "security", "workflow", "automation"], + "skills": "../../skills/", + "mcpServers": "../../.mcp.json", + "interface": { + "displayName": "ECC", + "shortDescription": "249 ECC skills plus MCP configs for TDD, security, code review, and autonomous development.", + "longDescription": "ECC is a harness-native operator system for Codex and adjacent agent harnesses. It packages reusable skills, MCP configs, TDD workflows, security scanning, code review, architecture decisions, operator workflows, and release gates in one installable plugin.", + "developerName": "Affaan Mustafa", + "category": "Coding", + "capabilities": ["Interactive", "Read", "Write"], + "websiteURL": "https://ecc.tools", + "privacyPolicyURL": "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement", + "termsOfServiceURL": "https://docs.github.com/en/site-policy/github-terms/github-terms-of-service", + "brandColor": "#E07856", + "composerIcon": "../../assets/ecc-icon.svg", + "logo": "../../assets/hero.png", + "screenshots": [], + "defaultPrompt": [ + "Use the tdd-workflow skill to write tests before implementation.", + "Use the security-review skill to scan for OWASP Top 10 vulnerabilities.", + "Use the verification-loop skill to verify correctness before shipping changes." + ] + } +} diff --git a/plugins/ecc/README.md b/plugins/ecc/README.md new file mode 100644 index 00000000..c14d42d0 --- /dev/null +++ b/plugins/ecc/README.md @@ -0,0 +1,41 @@ +# plugins/ecc — Codex Repo-Marketplace Plugin Target + +This directory is the plugin folder that `.agents/plugins/marketplace.json` +points at. Codex does not discover plugins whose local marketplace +`source.path` is the marketplace root itself (`./`), so the marketplace entry +must target a concrete plugin subdirectory — verified against Codex CLI +0.137.0 and the official plugin docs (`$REPO_ROOT/plugins/`). + +## Single source of truth + +Per the repo's no-duplication policy, no skill or MCP content is vendored +here. `.codex-plugin/plugin.json` references the canonical root content with +parent-relative paths: + +| Manifest field | Resolves to | +|---|---| +| `skills` | `skills/` at the repo root | +| `mcpServers` | `.mcp.json` at the repo root | +| `interface.composerIcon` / `interface.logo` | `assets/` at the repo root | + +The canonical Codex plugin manifest for the repo-root bundle (used by the +official `openai/plugins` directory shape and other harness tooling) remains +at `.codex-plugin/plugin.json`. Keep `name` and `version` in both manifests in +sync — `tests/plugin-manifest.test.js` enforces this and `scripts/release.sh` +bumps both. + +## Current Codex plugin-mode status + +With this layout, `codex plugin marketplace add affaan-m/ECC` discovers and +installs `ecc@ecc`. Runtime skill loading from repo marketplaces is still +unreliable upstream — Codex copies only the plugin folder into its install +cache, and local/personal marketplace plugins are not always exposed at +runtime (see [openai/codex#26037](https://github.com/openai/codex/issues/26037) +and [affaan-m/ECC#2128](https://github.com/affaan-m/ECC/issues/2128)). + +Until the upstream discovery issues settle, the supported Codex path is the +manual sync flow documented in the README: + +```bash +npm install && bash scripts/sync-ecc-to-codex.sh +``` diff --git a/scripts/hooks/suggest-compact.js b/scripts/hooks/suggest-compact.js index 0638ae2b..2a104df3 100644 --- a/scripts/hooks/suggest-compact.js +++ b/scripts/hooks/suggest-compact.js @@ -11,6 +11,16 @@ * - Strategic compacting preserves context through logical phases * - Compact after exploration, before execution * - Compact after completing a milestone, before starting next + * + * Two signals (#2155): + * - Tool-call count: first at COMPACT_THRESHOLD (default 50), then every 25. + * - Context size (primary): the latest assistant `usage` record from the + * session transcript, compared against a window-scaled token threshold + * (COMPACT_CONTEXT_THRESHOLD; default 160k on a 200k window, 250k on 1M), + * re-reminding after every COMPACT_CONTEXT_INTERVAL tokens of growth + * (default 60k). Tool count is a weak proxy for window pressure — a few + * large reads can fill the window in very few calls, and many tiny calls + * can cross 50 while the window is barely used. */ const fs = require('fs'); @@ -22,8 +32,18 @@ const { log, output } = require('../lib/utils'); +const { + readLatestContextTokens, + resolveContextWindowTokens, + resolveContextThreshold, + resolveContextInterval, + computeContextBucket, + formatWindowLabel +} = require('../lib/transcript-context'); const COUNTER_FILE_PREFIX = 'claude-tool-count-'; +const CONTEXT_BUCKET_FILE_PREFIX = 'claude-context-bucket-'; +const STATE_FILE_PREFIXES = [COUNTER_FILE_PREFIX, CONTEXT_BUCKET_FILE_PREFIX]; const DEFAULT_COMPACT_STATE_TTL_DAYS = 14; function getCounterRetentionDays() { @@ -34,23 +54,24 @@ function getCounterRetentionDays() { } /** - * Sweep stale counter files from the temp dir. + * Sweep stale per-session state files from the temp dir. * - * Each session writes `claude-tool-count-` into the OS temp - * dir; nothing else removes them. Without a sweep these files accumulate - * one-per-session forever. This helper removes counters whose mtime is - * older than `retentionDays`, while preserving the active session's - * counter (which is about to be re-written by the caller). + * Each session writes `claude-tool-count-` (and, with the context + * signal, `claude-context-bucket-`) into the OS temp dir; nothing + * else removes them. Without a sweep these files accumulate one-per-session + * forever. This helper removes state files whose mtime is older than + * `retentionDays`, while preserving the active session's files (which are + * about to be re-written by the caller). * * The helper never throws; per the always-exit-0 hook contract any * filesystem failure is swallowed and logged to stderr. * * @param {string} tempDir - The temp directory to sweep. * @param {number} retentionDays - Files older than this many days are removed. - * @param {string} currentCounterFile - Absolute path of the active session's - * counter file; preserved unconditionally. + * @param {string[]} currentStateFiles - Absolute paths of the active session's + * state files; preserved unconditionally. */ -function cleanupOldCounters(tempDir, retentionDays, currentCounterFile) { +function cleanupOldCounters(tempDir, retentionDays, currentStateFiles) { let entries; try { entries = fs.readdirSync(tempDir, { withFileTypes: true }); @@ -60,12 +81,12 @@ function cleanupOldCounters(tempDir, retentionDays, currentCounterFile) { } const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000; - const currentBasename = path.basename(currentCounterFile); + const currentBasenames = new Set(currentStateFiles.map(filePath => path.basename(filePath))); for (const entry of entries) { if (!entry.isFile()) continue; - if (!entry.name.startsWith(COUNTER_FILE_PREFIX)) continue; - if (entry.name === currentBasename) continue; + if (!STATE_FILE_PREFIXES.some(prefix => entry.name.startsWith(prefix))) continue; + if (currentBasenames.has(entry.name)) continue; const fullPath = path.join(tempDir, entry.name); let stats; @@ -89,43 +110,14 @@ function cleanupOldCounters(tempDir, retentionDays, currentCounterFile) { } } -async function resolveSessionId() { - // Claude Code passes hook input via stdin JSON; session_id is the - // canonical field. Fall back to the legacy env var, then 'default'. - try { - const input = await readStdinJson({ timeoutMs: 1000 }); - if (input && typeof input.session_id === 'string' && input.session_id) { - return input.session_id; - } - } catch { - /* fall through to env */ - } - return process.env.CLAUDE_SESSION_ID || 'default'; -} - -async function main() { - // Track tool call count (increment in a temp file) - // Use a session-specific counter file based on session ID from stdin JSON, - // legacy env var, or 'default' as fallback. - const rawSessionId = await resolveSessionId(); - const sessionId = rawSessionId.replace(/[^a-zA-Z0-9_-]/g, '') || 'default'; - const tempDir = getTempDir(); - const counterFile = path.join(tempDir, `${COUNTER_FILE_PREFIX}${sessionId}`); - - // Sweep stale counter files (concern 1 of #2156). Cheap, swallows errors, - // skips the active session's file. See cleanupOldCounters for details. - cleanupOldCounters(tempDir, getCounterRetentionDays(), counterFile); - - const rawThreshold = parseInt(process.env.COMPACT_THRESHOLD || '50', 10); - const threshold = Number.isFinite(rawThreshold) && rawThreshold > 0 && rawThreshold <= 10000 - ? rawThreshold - : 50; - +/** + * Increment and persist the per-session tool-call counter. + * Uses fd-based read+write to reduce (but not eliminate) the race window + * between concurrent hook invocations. + */ +function incrementToolCallCount(counterFile) { let count = 1; - // Read existing count or start at 1 - // Use fd-based read+write to reduce (but not eliminate) race window - // between concurrent hook invocations try { const fd = fs.openSync(counterFile, 'a+'); try { @@ -150,25 +142,124 @@ async function main() { writeFile(counterFile, String(count)); } - // Suggest compact after threshold tool calls. - // + return count; +} + +/** + * Read the last context bucket this session already fired for (-1 when the + * suggestion has not fired yet or the state file is unreadable/corrupted). + */ +function readLastContextBucket(bucketFile) { + try { + const parsed = parseInt(fs.readFileSync(bucketFile, 'utf8').trim(), 10); + return Number.isInteger(parsed) && parsed >= 0 && parsed <= 1000000 ? parsed : -1; + } catch { + return -1; + } +} + +/** + * Build the context-size suggestion when the transcript shows the session has + * crossed into a new context bucket. Returns null when the signal is silent + * (no transcript, below threshold, disabled, or already fired for the bucket). + * + * Never throws — any transcript or state-file failure silently disables the + * signal so the hook keeps its always-exit-0 contract. + */ +function buildContextSuggestion(transcriptPath, bucketFile, env) { + try { + const usage = readLatestContextTokens(transcriptPath); + if (!usage) return null; + + const windowTokens = resolveContextWindowTokens(usage.tokens, usage.model); + const threshold = resolveContextThreshold(env, windowTokens); + if (threshold <= 0) return null; // COMPACT_CONTEXT_THRESHOLD=0 disables + + const interval = resolveContextInterval(env); + const bucket = computeContextBucket(usage.tokens, threshold, interval); + if (bucket < 0) return null; + + const lastBucket = readLastContextBucket(bucketFile); + if (bucket <= lastBucket) return null; + + writeFile(bucketFile, String(bucket)); + + const approxTokens = `${Math.round(usage.tokens / 1000)}k`; + const percent = Math.round((usage.tokens / windowTokens) * 100); + return `[StrategicCompact] Context ~${approxTokens} tokens (${percent}% of ${formatWindowLabel(windowTokens)} window) - consider /compact at the next logical boundary`; + } catch (err) { + log(`[StrategicCompact] Context signal skipped: ${err.message}`); + return null; + } +} + +async function main() { + // Claude Code passes hook input via stdin JSON; session_id is the + // canonical field (legacy env var, then 'default', as fallbacks) and + // transcript_path points at the session transcript JSONL used by the + // context-size signal. + let input = {}; + try { + input = await readStdinJson({ timeoutMs: 1000 }); + } catch { + input = {}; + } + + const rawSessionId = (input && typeof input.session_id === 'string' && input.session_id) + ? input.session_id + : (process.env.CLAUDE_SESSION_ID || 'default'); + const sessionId = rawSessionId.replace(/[^a-zA-Z0-9_-]/g, '') || 'default'; + const transcriptPath = (input && typeof input.transcript_path === 'string') ? input.transcript_path : ''; + + const tempDir = getTempDir(); + const counterFile = path.join(tempDir, `${COUNTER_FILE_PREFIX}${sessionId}`); + const bucketFile = path.join(tempDir, `${CONTEXT_BUCKET_FILE_PREFIX}${sessionId}`); + + // Sweep stale state files (concern 1 of #2156). Cheap, swallows errors, + // skips the active session's files. See cleanupOldCounters for details. + cleanupOldCounters(tempDir, getCounterRetentionDays(), [counterFile, bucketFile]); + + const rawThreshold = parseInt(process.env.COMPACT_THRESHOLD || '50', 10); + const threshold = Number.isFinite(rawThreshold) && rawThreshold > 0 && rawThreshold <= 10000 + ? rawThreshold + : 50; + + const count = incrementToolCallCount(counterFile); + + const messages = []; + + // Primary signal (#2155): real context size from the transcript's latest + // usage record. Fires at a window-scaled token threshold and re-fires only + // after the context grows by another interval step. + const contextSuggestion = buildContextSuggestion(transcriptPath, bucketFile, process.env); + if (contextSuggestion) { + messages.push(contextSuggestion); + } + + // Secondary signal: tool-call count at threshold, then every 25 calls. + if (count === threshold) { + messages.push(`[StrategicCompact] ${threshold} tool calls reached - consider /compact if transitioning phases`); + } else if (count > threshold && (count - threshold) % 25 === 0) { + messages.push(`[StrategicCompact] ${count} tool calls - good checkpoint for /compact if context is stale`); + } + // log() writes to stderr (debug log). Per the Claude Code hooks guide, // non-blocking PreToolUse stderr (exit 0) is only written to the debug log; // it does not reach the model. To inject a user-facing suggestion without // blocking the tool call, emit structured JSON to stdout with // hookSpecificOutput.additionalContext — the documented mechanism for - // PreToolUse hooks to add context to the next model turn. - if (count === threshold) { - const msg = `[StrategicCompact] ${threshold} tool calls reached - consider /compact if transitioning phases`; - log(msg); - output({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: msg } }); - } - - // Suggest at regular intervals after threshold (every 25 calls from threshold) - if (count > threshold && (count - threshold) % 25 === 0) { - const msg = `[StrategicCompact] ${count} tool calls - good checkpoint for /compact if context is stale`; - log(msg); - output({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: msg } }); + // PreToolUse hooks to add context to the next model turn. Hooks must emit + // at most one stdout JSON payload per run, so both signals share it. + if (messages.length > 0) { + for (const msg of messages) { + log(msg); + } + output({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + additionalContext: messages.join('\n') + } + }); } process.exit(0); diff --git a/scripts/lib/transcript-context.js b/scripts/lib/transcript-context.js new file mode 100644 index 00000000..4a00a551 --- /dev/null +++ b/scripts/lib/transcript-context.js @@ -0,0 +1,226 @@ +/** + * Transcript context-size helpers for the strategic-compact hook (#2155). + * + * Reads the latest assistant `usage` record from a Claude Code session + * transcript (JSONL) and derives a context-size signal: + * + * - `input_tokens + cache_read_input_tokens + cache_creation_input_tokens` + * partition the prompt, so their sum is the true context size of the turn. + * - The context window is detected from the model id (`[1m]` marker) or from + * the observed token count (anything above 200k implies a 1M window even + * when logs drop the suffix). + * - Thresholds are window-scaled and env-overridable; re-reminders fire in + * fixed token "buckets" above the threshold so the suggestion only repeats + * after real context growth. + * + * Only the tail of the transcript is read (latest records live at the end), + * keeping the PreToolUse hook fast even for very large sessions. + */ + +const fs = require('fs'); + +const STANDARD_CONTEXT_WINDOW_TOKENS = 200000; +const LARGE_CONTEXT_WINDOW_TOKENS = 1000000; +const DEFAULT_CONTEXT_THRESHOLD_STANDARD = 160000; +const DEFAULT_CONTEXT_THRESHOLD_LARGE = 250000; +const DEFAULT_CONTEXT_INTERVAL_TOKENS = 60000; +const DEFAULT_TRANSCRIPT_TAIL_BYTES = 256 * 1024; +const MAX_TOKEN_SETTING = 10000000; +const LARGE_WINDOW_MODEL_MARKER = '[1m]'; + +/** + * Read the trailing `tailBytes` of a file as UTF-8. + * Returns null when the file is missing or unreadable. + */ +function readFileTail(filePath, tailBytes) { + let fd; + try { + fd = fs.openSync(filePath, 'r'); + } catch { + return null; + } + + try { + const size = fs.fstatSync(fd).size; + const start = Math.max(0, size - tailBytes); + const length = size - start; + if (length <= 0) { + return { text: '', truncated: false }; + } + + const buffer = Buffer.alloc(length); + const bytesRead = fs.readSync(fd, buffer, 0, length, start); + return { + text: buffer.toString('utf8', 0, bytesRead), + truncated: start > 0 + }; + } catch { + return null; + } finally { + try { + fs.closeSync(fd); + } catch { + /* ignore */ + } + } +} + +/** + * Extract the context token total from a transcript record's usage block. + * Returns 0 when the record carries no usable usage data. + */ +function extractUsageTokens(record) { + const usage = record && record.message && record.message.usage; + if (!usage || typeof usage !== 'object') { + return 0; + } + + const total = + (Number.isFinite(usage.input_tokens) ? usage.input_tokens : 0) + + (Number.isFinite(usage.cache_read_input_tokens) ? usage.cache_read_input_tokens : 0) + + (Number.isFinite(usage.cache_creation_input_tokens) ? usage.cache_creation_input_tokens : 0); + + return total > 0 ? total : 0; +} + +/** + * Scan a session transcript (JSONL) backwards for the most recent record with + * a non-empty `message.usage` block. + * + * @param {string} transcriptPath - Absolute path to the transcript JSONL. + * @param {object} [options] + * @param {number} [options.tailBytes] - How many trailing bytes to scan. + * @returns {{ tokens: number, model: string } | null} Latest context size, or + * null when the transcript is missing, unreadable, or has no usage records. + */ +function readLatestContextTokens(transcriptPath, options = {}) { + if (typeof transcriptPath !== 'string' || !transcriptPath) { + return null; + } + + const tailBytes = Number.isInteger(options.tailBytes) && options.tailBytes > 0 + ? options.tailBytes + : DEFAULT_TRANSCRIPT_TAIL_BYTES; + + const tail = readFileTail(transcriptPath, tailBytes); + if (!tail) { + return null; + } + + const lines = tail.text.split('\n'); + // The first line of a truncated tail is almost certainly partial JSON. + const firstLine = tail.truncated ? 1 : 0; + + for (let i = lines.length - 1; i >= firstLine; i--) { + const line = lines[i].trim(); + if (!line) continue; + + let record; + try { + record = JSON.parse(line); + } catch { + continue; + } + + const tokens = extractUsageTokens(record); + if (tokens > 0) { + const model = record.message && typeof record.message.model === 'string' + ? record.message.model + : ''; + return { tokens, model }; + } + } + + return null; +} + +/** + * Detect the context window size for a turn. + * 1M when the model id carries the `[1m]` marker, or when the observed token + * count already exceeds the standard 200k window (covers logs that drop the + * suffix); otherwise the standard 200k window. + */ +function resolveContextWindowTokens(tokens, model) { + if (typeof model === 'string' && model.includes(LARGE_WINDOW_MODEL_MARKER)) { + return LARGE_CONTEXT_WINDOW_TOKENS; + } + + if (Number.isFinite(tokens) && tokens > STANDARD_CONTEXT_WINDOW_TOKENS) { + return LARGE_CONTEXT_WINDOW_TOKENS; + } + + return STANDARD_CONTEXT_WINDOW_TOKENS; +} + +/** + * Resolve the context-size suggestion threshold (tokens). + * `COMPACT_CONTEXT_THRESHOLD=0` disables the context signal entirely; + * other invalid values fall back to the window-scaled default. + */ +function resolveContextThreshold(env, windowTokens) { + const raw = env && env.COMPACT_CONTEXT_THRESHOLD; + if (raw !== undefined && raw !== null && raw !== '') { + const parsed = Number.parseInt(raw, 10); + if (parsed === 0) { + return 0; + } + if (Number.isInteger(parsed) && parsed > 0 && parsed <= MAX_TOKEN_SETTING) { + return parsed; + } + } + + return windowTokens >= LARGE_CONTEXT_WINDOW_TOKENS + ? DEFAULT_CONTEXT_THRESHOLD_LARGE + : DEFAULT_CONTEXT_THRESHOLD_STANDARD; +} + +/** + * Resolve the re-reminder step (tokens of additional context growth before + * the suggestion repeats). Invalid values fall back to the default. + */ +function resolveContextInterval(env) { + const raw = env && env.COMPACT_CONTEXT_INTERVAL; + const parsed = Number.parseInt(raw, 10); + return Number.isInteger(parsed) && parsed > 0 && parsed <= MAX_TOKEN_SETTING + ? parsed + : DEFAULT_CONTEXT_INTERVAL_TOKENS; +} + +/** + * Map a context size onto a suggestion bucket. + * Returns -1 below the threshold; bucket 0 at the threshold; +1 for every + * `interval` tokens of growth beyond it. The hook fires only when the bucket + * rises above the last bucket it already fired for. + */ +function computeContextBucket(tokens, threshold, interval) { + if (!Number.isFinite(tokens) || threshold <= 0 || tokens < threshold) { + return -1; + } + + const step = Number.isInteger(interval) && interval > 0 ? interval : DEFAULT_CONTEXT_INTERVAL_TOKENS; + return Math.floor((tokens - threshold) / step); +} + +/** + * Human-readable label for a context window size (e.g. "200k", "1M"). + */ +function formatWindowLabel(windowTokens) { + return windowTokens >= LARGE_CONTEXT_WINDOW_TOKENS + ? '1M' + : `${Math.round(windowTokens / 1000)}k`; +} + +module.exports = { + STANDARD_CONTEXT_WINDOW_TOKENS, + LARGE_CONTEXT_WINDOW_TOKENS, + DEFAULT_CONTEXT_THRESHOLD_STANDARD, + DEFAULT_CONTEXT_THRESHOLD_LARGE, + DEFAULT_CONTEXT_INTERVAL_TOKENS, + DEFAULT_TRANSCRIPT_TAIL_BYTES, + readLatestContextTokens, + resolveContextWindowTokens, + resolveContextThreshold, + resolveContextInterval, + computeContextBucket, + formatWindowLabel +}; diff --git a/scripts/release.sh b/scripts/release.sh index 5276a07d..c14ce576 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" +CODEX_MARKETPLACE_PLUGIN_JSON="plugins/ecc/.codex-plugin/plugin.json" OPENCODE_PACKAGE_JSON=".opencode/package.json" OPENCODE_PACKAGE_LOCK_JSON=".opencode/package-lock.json" OPENCODE_ECC_HOOKS_PLUGIN=".opencode/plugins/ecc-hooks.ts" @@ -270,6 +271,7 @@ update_version "$PLUGIN_JSON" "s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSIO update_version "$MARKETPLACE_JSON" "0,/\"version\": *\"[^\"]*\"/s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|" update_codex_marketplace_version update_version "$CODEX_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" update_opencode_hook_banner_version diff --git a/skills/strategic-compact/SKILL.md b/skills/strategic-compact/SKILL.md index 27e5f20f..8a0fa9e7 100644 --- a/skills/strategic-compact/SKILL.md +++ b/skills/strategic-compact/SKILL.md @@ -30,11 +30,12 @@ Strategic compaction at logical boundaries: ## How It Works -The `suggest-compact.js` script runs on PreToolUse (Edit/Write) and: +The `suggest-compact.js` script runs on PreToolUse (Edit/Write) and combines two signals: -1. **Tracks tool calls** — Counts tool invocations in session -2. **Threshold detection** — Suggests at configurable threshold (default: 50 calls) -3. **Periodic reminders** — Reminds every 25 calls after threshold +1. **Context size (primary)** — Reads the latest `usage` record from the session transcript (`transcript_path` in the hook payload) and sums `input_tokens + cache_read_input_tokens + cache_creation_input_tokens` (the true context size of the turn). Suggests `/compact` at a window-scaled threshold — 160k tokens on a 200k window, 250k on a 1M window (detected from a `[1m]` model marker, or inferred when observed tokens already exceed 200k) — and re-reminds after every additional 60k tokens of context growth +2. **Tool-call count (secondary)** — Counts tool invocations in session; suggests at a configurable threshold (default: 50 calls), then every 25 calls after + +Tool count alone is a weak proxy for window pressure: a few large file reads or MCP responses can fill the window in very few calls, while many tiny calls can cross 50 with a near-empty window. The context-size signal fires when it actually matters. ## Hook Setup @@ -61,6 +62,9 @@ Add to your `~/.claude/settings.json`: Environment variables: - `COMPACT_THRESHOLD` — Tool calls before first suggestion (default: 50) +- `COMPACT_CONTEXT_THRESHOLD` — Context tokens before the context-size suggestion (default: 160000 on a 200k window, 250000 on a 1M window; `0` disables the context signal) +- `COMPACT_CONTEXT_INTERVAL` — Additional context tokens before the suggestion repeats (default: 60000) +- `COMPACT_STATE_TTL_DAYS` — Days before stale per-session state files in the temp dir are swept (default: 14) ## Compaction Decision Guide diff --git a/tests/hooks/suggest-compact.test.js b/tests/hooks/suggest-compact.test.js index 2ad564a4..0389f70e 100644 --- a/tests/hooks/suggest-compact.test.js +++ b/tests/hooks/suggest-compact.test.js @@ -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}`); diff --git a/tests/lib/transcript-context.test.js b/tests/lib/transcript-context.test.js new file mode 100644 index 00000000..e6df6565 --- /dev/null +++ b/tests/lib/transcript-context.test.js @@ -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); diff --git a/tests/plugin-manifest.test.js b/tests/plugin-manifest.test.js index bfbaa1bf..985d8521 100644 --- a/tests/plugin-manifest.test.js +++ b/tests/plugin-manifest.test.js @@ -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/). 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); }); diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index 0f52ccae..aeb4faac 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -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",