mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-13 19:51:24 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b173d2e6c | |||
| 7777656bf5 | |||
| fec84fcf19 | |||
| 1481aa707e | |||
| 6c39cdecd3 | |||
| 42fe8c3083 | |||
| 77195eb7d8 | |||
| 75b5d64fc3 | |||
| 16be4a6898 | |||
| 967940f43e | |||
| e4a0062d9b | |||
| 66ad878e68 | |||
| 6da4490c76 | |||
| 6626e804f9 | |||
| 6319c7d309 |
@@ -9,7 +9,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"source": {
|
"source": {
|
||||||
"source": "local",
|
"source": "local",
|
||||||
"path": "./"
|
"path": "./plugins/ecc"
|
||||||
},
|
},
|
||||||
"policy": {
|
"policy": {
|
||||||
"installation": "AVAILABLE",
|
"installation": "AVAILABLE",
|
||||||
|
|||||||
@@ -29,11 +29,10 @@ Strategic compaction at logical boundaries:
|
|||||||
|
|
||||||
## How It Works
|
## 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
|
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. **Threshold detection** — Suggests at configurable threshold (default: 50 calls)
|
2. **Tool-call count (secondary)** — Counts tool invocations in session; suggests at a configurable threshold (default: 50 calls), then every 25 calls after
|
||||||
3. **Periodic reminders** — Reminds every 25 calls after threshold
|
|
||||||
|
|
||||||
## Hook Setup
|
## Hook Setup
|
||||||
|
|
||||||
@@ -60,6 +59,8 @@ Add to your `~/.claude/settings.json`:
|
|||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
- `COMPACT_THRESHOLD` — Tool calls before first suggestion (default: 50)
|
- `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
|
## Compaction Decision Guide
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ecc",
|
"name": "ecc",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"description": "Harness-native ECC operator layer - 64 agents, 261 skills, 84 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
|
"description": "Harness-native ECC operator layer - 64 agents, 262 skills, 84 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Affaan Mustafa",
|
"name": "Affaan Mustafa",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ecc",
|
"name": "ecc",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"description": "Harness-native ECC plugin for engineering teams - 64 agents, 261 skills, 84 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
|
"description": "Harness-native ECC plugin for engineering teams - 64 agents, 262 skills, 84 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Affaan Mustafa",
|
"name": "Affaan Mustafa",
|
||||||
"url": "https://x.com/affaanmustafa"
|
"url": "https://x.com/affaanmustafa"
|
||||||
|
|||||||
+16
-4
@@ -30,10 +30,22 @@ codex plugin marketplace add affaan-m/ECC
|
|||||||
codex plugin marketplace add /absolute/path/to/ECC
|
codex plugin marketplace add /absolute/path/to/ECC
|
||||||
```
|
```
|
||||||
|
|
||||||
The marketplace entry points at the repository root so `.codex-plugin/plugin.json`,
|
The marketplace entry points at `plugins/ecc/` — Codex does not discover
|
||||||
`skills/`, and `.mcp.json` resolve from one shared source of truth. After adding
|
plugins whose local marketplace `source.path` is the marketplace root (`./`),
|
||||||
or updating the marketplace, restart Codex and install or enable `ecc` from the
|
so the entry must target a concrete plugin subdirectory (see
|
||||||
plugin directory.
|
[#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
|
Official Plugin Directory publishing is coming soon. For official OpenAI
|
||||||
plugin-directory review, package this repo under the `openai/plugins`
|
plugin-directory review, package this repo under the `openai/plugins`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Everything Claude Code (ECC) — Agent Instructions
|
# Everything Claude Code (ECC) — Agent Instructions
|
||||||
|
|
||||||
This is a **production-ready AI coding plugin** providing 64 specialized agents, 261 skills, 84 commands, and automated hook workflows for software development.
|
This is a **production-ready AI coding plugin** providing 64 specialized agents, 262 skills, 84 commands, and automated hook workflows for software development.
|
||||||
|
|
||||||
**Version:** 2.0.0
|
**Version:** 2.0.0
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
|
|||||||
|
|
||||||
```
|
```
|
||||||
agents/ — 64 specialized subagents
|
agents/ — 64 specialized subagents
|
||||||
skills/ — 261 workflow skills and domain knowledge
|
skills/ — 262 workflow skills and domain knowledge
|
||||||
commands/ — 84 slash commands
|
commands/ — 84 slash commands
|
||||||
hooks/ — Trigger-based automations
|
hooks/ — Trigger-based automations
|
||||||
rules/ — Always-follow guidelines (common + per-language)
|
rules/ — Always-follow guidelines (common + per-language)
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
[](https://github.com/affaan-m/ECC/stargazers)
|
[](https://github.com/affaan-m/ECC/stargazers)
|
||||||
[](https://github.com/affaan-m/ECC/network/members)
|
[](https://github.com/affaan-m/ECC/network/members)
|
||||||
[](https://github.com/affaan-m/ECC/graphs/contributors)
|
[](https://github.com/affaan-m/ECC/graphs/contributors)
|
||||||
[](https://www.npmjs.com/package/ecc-universal)
|
[](https://www.npmjs.com/package/ecc-universal)
|
||||||
[](https://www.npmjs.com/package/ecc-agentshield)
|
[](https://www.npmjs.com/package/ecc-agentshield)
|
||||||
[](https://github.com/marketplace/ecc-tools)
|
[](https://github.com/marketplace/ecc-tools)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||

|

|
||||||

|

|
||||||
@@ -154,7 +154,7 @@ Stable graduation of the 2.0 line: 261 skills, the control-pane substrate (sessi
|
|||||||
### v2.0.0-rc.1 — Surface Refresh, Operator Workflows, and ECC 2.0 Alpha (Apr 2026)
|
### v2.0.0-rc.1 — Surface Refresh, Operator Workflows, and ECC 2.0 Alpha (Apr 2026)
|
||||||
|
|
||||||
- **Dashboard GUI** — New Tkinter-based desktop application (`ecc_dashboard.py` or `npm run dashboard`) with dark/light theme toggle, font customization, and project logo in header and taskbar.
|
- **Dashboard GUI** — New Tkinter-based desktop application (`ecc_dashboard.py` or `npm run dashboard`) with dark/light theme toggle, font customization, and project logo in header and taskbar.
|
||||||
- **Public surface synced to the live repo** — metadata, catalog counts, plugin manifests, and install-facing docs now match the actual OSS surface: 64 agents, 261 skills, and 84 legacy command shims.
|
- **Public surface synced to the live repo** — metadata, catalog counts, plugin manifests, and install-facing docs now match the actual OSS surface: 64 agents, 262 skills, and 84 legacy command shims.
|
||||||
- **Operator and outbound workflow expansion** — `brand-voice`, `social-graph-ranker`, `connections-optimizer`, `customer-billing-ops`, `ecc-tools-cost-audit`, `google-workspace-ops`, `project-flow-ops`, and `workspace-surface-audit` round out the operator lane.
|
- **Operator and outbound workflow expansion** — `brand-voice`, `social-graph-ranker`, `connections-optimizer`, `customer-billing-ops`, `ecc-tools-cost-audit`, `google-workspace-ops`, `project-flow-ops`, and `workspace-surface-audit` round out the operator lane.
|
||||||
- **Media and launch tooling** — `manim-video`, `remotion-video-creation`, and upgraded social publishing surfaces make technical explainers and launch content part of the same system.
|
- **Media and launch tooling** — `manim-video`, `remotion-video-creation`, and upgraded social publishing surfaces make technical explainers and launch content part of the same system.
|
||||||
- **Framework and product surface growth** — `nestjs-patterns`, richer Codex/OpenCode install surfaces, and expanded cross-harness packaging keep the repo usable beyond Claude Code alone.
|
- **Framework and product surface growth** — `nestjs-patterns`, richer Codex/OpenCode install surfaces, and expanded cross-harness packaging keep the repo usable beyond Claude Code alone.
|
||||||
@@ -425,7 +425,7 @@ If you stacked methods, clean up in this order:
|
|||||||
/plugin list ecc@ecc
|
/plugin list ecc@ecc
|
||||||
```
|
```
|
||||||
|
|
||||||
**That's it!** You now have access to 64 agents, 261 skills, and 84 legacy command shims.
|
**That's it!** You now have access to 64 agents, 262 skills, and 84 legacy command shims.
|
||||||
|
|
||||||
### Dashboard GUI
|
### Dashboard GUI
|
||||||
|
|
||||||
@@ -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.
|
- 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/`.
|
- 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
|
### What's Included
|
||||||
|
|
||||||
| Component | Count | Details |
|
| Component | Count | Details |
|
||||||
@@ -1498,7 +1509,7 @@ The configuration is automatically detected from `.opencode/opencode.json`.
|
|||||||
|---------|---------------------|----------|--------|
|
|---------|---------------------|----------|--------|
|
||||||
| Agents | PASS: 64 agents | PASS: 12 agents | **Claude Code leads** |
|
| Agents | PASS: 64 agents | PASS: 12 agents | **Claude Code leads** |
|
||||||
| Commands | PASS: 84 commands | PASS: 35 commands | **Claude Code leads** |
|
| Commands | PASS: 84 commands | PASS: 35 commands | **Claude Code leads** |
|
||||||
| Skills | PASS: 261 skills | PASS: 37 skills | **Claude Code leads** |
|
| Skills | PASS: 262 skills | PASS: 37 skills | **Claude Code leads** |
|
||||||
| Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** |
|
| Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** |
|
||||||
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
|
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
|
||||||
| MCP Servers | PASS: 14 servers | PASS: Full | **Full parity** |
|
| MCP Servers | PASS: 14 servers | PASS: Full | **Full parity** |
|
||||||
@@ -1659,7 +1670,7 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e
|
|||||||
|---------|-----------------------|------------|-----------|----------|----------------|
|
|---------|-----------------------|------------|-----------|----------|----------------|
|
||||||
| **Agents** | 64 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 | N/A |
|
| **Agents** | 64 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 | N/A |
|
||||||
| **Commands** | 84 | Shared | Instruction-based | 35 | 5 prompts |
|
| **Commands** | 84 | Shared | Instruction-based | 35 | 5 prompts |
|
||||||
| **Skills** | 261 | Shared | 10 (native format) | 37 | Via instructions |
|
| **Skills** | 262 | Shared | 10 (native format) | 37 | Via instructions |
|
||||||
| **Hook Events** | 8 types | 15 types | None yet | 11 types | None |
|
| **Hook Events** | 8 types | 15 types | None yet | 11 types | None |
|
||||||
| **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks | N/A |
|
| **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks | N/A |
|
||||||
| **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions | 1 always-on file |
|
| **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions | 1 always-on file |
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
[](https://github.com/affaan-m/everything-claude-code/graphs/contributors)
|
[](https://github.com/affaan-m/everything-claude-code/graphs/contributors)
|
||||||
[](https://www.npmjs.com/package/ecc-universal)
|
[](https://www.npmjs.com/package/ecc-universal)
|
||||||
[](https://www.npmjs.com/package/ecc-agentshield)
|
[](https://www.npmjs.com/package/ecc-agentshield)
|
||||||
[](https://github.com/marketplace/ecc-tools)
|
[](https://github.com/marketplace/ecc-tools)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||

|

|
||||||

|

|
||||||
@@ -164,7 +164,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
|||||||
/plugin list ecc@ecc
|
/plugin list ecc@ecc
|
||||||
```
|
```
|
||||||
|
|
||||||
**完成!** 你现在可以使用 64 个代理、261 个技能和 84 个命令。
|
**完成!** 你现在可以使用 64 个代理、262 个技能和 84 个命令。
|
||||||
|
|
||||||
### multi-* 命令需要额外配置
|
### multi-* 命令需要额外配置
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
+9
-1
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 22 KiB |
@@ -4,12 +4,12 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
[](https://github.com/affaan-m/ECC/stargazers)
|
[](https://github.com/affaan-m/ECC/stargazers)
|
||||||
[](https://github.com/affaan-m/ECC/network/members)
|
[](https://github.com/affaan-m/ECC/network/members)
|
||||||
[](https://github.com/affaan-m/ECC/graphs/contributors)
|
[](https://github.com/affaan-m/ECC/graphs/contributors)
|
||||||
[](https://www.npmjs.com/package/ecc-universal)
|
[](https://www.npmjs.com/package/ecc-universal)
|
||||||
[](https://www.npmjs.com/package/ecc-agentshield)
|
[](https://www.npmjs.com/package/ecc-agentshield)
|
||||||
[](https://github.com/marketplace/ecc-tools)
|
[](https://github.com/marketplace/ecc-tools)
|
||||||
[](../../LICENSE)
|
[](../../LICENSE)
|
||||||

|

|
||||||

|

|
||||||
|
|||||||
+3
-3
@@ -4,12 +4,12 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
[](https://github.com/affaan-m/ECC/stargazers)
|
[](https://github.com/affaan-m/ECC/stargazers)
|
||||||
[](https://github.com/affaan-m/ECC/network/members)
|
[](https://github.com/affaan-m/ECC/network/members)
|
||||||
[](https://github.com/affaan-m/ECC/graphs/contributors)
|
[](https://github.com/affaan-m/ECC/graphs/contributors)
|
||||||
[](https://www.npmjs.com/package/ecc-universal)
|
[](https://www.npmjs.com/package/ecc-universal)
|
||||||
[](https://www.npmjs.com/package/ecc-agentshield)
|
[](https://www.npmjs.com/package/ecc-agentshield)
|
||||||
[](https://github.com/marketplace/ecc-tools)
|
[](https://github.com/marketplace/ecc-tools)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||

|

|
||||||

|

|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
[](https://github.com/affaan-m/everything-claude-code/graphs/contributors)
|
[](https://github.com/affaan-m/everything-claude-code/graphs/contributors)
|
||||||
[](https://www.npmjs.com/package/ecc-universal)
|
[](https://www.npmjs.com/package/ecc-universal)
|
||||||
[](https://www.npmjs.com/package/ecc-agentshield)
|
[](https://www.npmjs.com/package/ecc-agentshield)
|
||||||
[](https://github.com/marketplace/ecc-tools)
|
[](https://github.com/marketplace/ecc-tools)
|
||||||
[](../../LICENSE)
|
[](../../LICENSE)
|
||||||

|

|
||||||

|

|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
[](https://github.com/affaan-m/everything-claude-code/graphs/contributors)
|
[](https://github.com/affaan-m/everything-claude-code/graphs/contributors)
|
||||||
[](https://www.npmjs.com/package/ecc-universal)
|
[](https://www.npmjs.com/package/ecc-universal)
|
||||||
[](https://www.npmjs.com/package/ecc-agentshield)
|
[](https://www.npmjs.com/package/ecc-agentshield)
|
||||||
[](https://github.com/marketplace/ecc-tools)
|
[](https://github.com/marketplace/ecc-tools)
|
||||||
[](../../LICENSE)
|
[](../../LICENSE)
|
||||||

|

|
||||||

|

|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
[](https://github.com/affaan-m/everything-claude-code/graphs/contributors)
|
[](https://github.com/affaan-m/everything-claude-code/graphs/contributors)
|
||||||
[](https://www.npmjs.com/package/ecc-universal)
|
[](https://www.npmjs.com/package/ecc-universal)
|
||||||
[](https://www.npmjs.com/package/ecc-agentshield)
|
[](https://www.npmjs.com/package/ecc-agentshield)
|
||||||
[](https://github.com/marketplace/ecc-tools)
|
[](https://github.com/marketplace/ecc-tools)
|
||||||
[](../../LICENSE)
|
[](../../LICENSE)
|
||||||

|

|
||||||

|

|
||||||
|
|||||||
+3
-3
@@ -4,12 +4,12 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
[](https://github.com/affaan-m/ECC/stargazers)
|
[](https://github.com/affaan-m/ECC/stargazers)
|
||||||
[](https://github.com/affaan-m/ECC/network/members)
|
[](https://github.com/affaan-m/ECC/network/members)
|
||||||
[](https://github.com/affaan-m/ECC/graphs/contributors)
|
[](https://github.com/affaan-m/ECC/graphs/contributors)
|
||||||
[](https://www.npmjs.com/package/ecc-universal)
|
[](https://www.npmjs.com/package/ecc-universal)
|
||||||
[](https://www.npmjs.com/package/ecc-agentshield)
|
[](https://www.npmjs.com/package/ecc-agentshield)
|
||||||
[](https://github.com/marketplace/ecc-tools)
|
[](https://github.com/marketplace/ecc-tools)
|
||||||
[](../../LICENSE)
|
[](../../LICENSE)
|
||||||

|

|
||||||

|

|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Everything Claude Code (ECC) — 智能体指令
|
# Everything Claude Code (ECC) — 智能体指令
|
||||||
|
|
||||||
这是一个**生产就绪的 AI 编码插件**,提供 64 个专业代理、261 项技能、84 条命令以及自动化钩子工作流,用于软件开发。
|
这是一个**生产就绪的 AI 编码插件**,提供 64 个专业代理、262 项技能、84 条命令以及自动化钩子工作流,用于软件开发。
|
||||||
|
|
||||||
**版本:** 2.0.0
|
**版本:** 2.0.0
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
agents/ — 64 个专业子代理
|
agents/ — 64 个专业子代理
|
||||||
skills/ — 261 个工作流技能和领域知识
|
skills/ — 262 个工作流技能和领域知识
|
||||||
commands/ — 84 个斜杠命令
|
commands/ — 84 个斜杠命令
|
||||||
hooks/ — 基于触发的自动化
|
hooks/ — 基于触发的自动化
|
||||||
rules/ — 始终遵循的指导方针(通用 + 每种语言)
|
rules/ — 始终遵循的指导方针(通用 + 每种语言)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
[](https://github.com/affaan-m/everything-claude-code/graphs/contributors)
|
[](https://github.com/affaan-m/everything-claude-code/graphs/contributors)
|
||||||
[](https://www.npmjs.com/package/ecc-universal)
|
[](https://www.npmjs.com/package/ecc-universal)
|
||||||
[](https://www.npmjs.com/package/ecc-agentshield)
|
[](https://www.npmjs.com/package/ecc-agentshield)
|
||||||
[](https://github.com/marketplace/ecc-tools)
|
[](https://github.com/marketplace/ecc-tools)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||

|

|
||||||

|

|
||||||
@@ -228,7 +228,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
|||||||
/plugin list ecc@ecc
|
/plugin list ecc@ecc
|
||||||
```
|
```
|
||||||
|
|
||||||
**搞定!** 你现在可以使用 64 个智能体、261 项技能和 84 个命令了。
|
**搞定!** 你现在可以使用 64 个智能体、262 项技能和 84 个命令了。
|
||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
@@ -1142,7 +1142,7 @@ opencode
|
|||||||
|---------|---------------|----------|--------|
|
|---------|---------------|----------|--------|
|
||||||
| 智能体 | PASS: 64 个 | PASS: 12 个 | **Claude Code 领先** |
|
| 智能体 | PASS: 64 个 | PASS: 12 个 | **Claude Code 领先** |
|
||||||
| 命令 | PASS: 84 个 | PASS: 35 个 | **Claude Code 领先** |
|
| 命令 | PASS: 84 个 | PASS: 35 个 | **Claude Code 领先** |
|
||||||
| 技能 | PASS: 261 项 | PASS: 37 项 | **Claude Code 领先** |
|
| 技能 | PASS: 262 项 | PASS: 37 项 | **Claude Code 领先** |
|
||||||
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
|
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
|
||||||
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
|
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
|
||||||
| MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** |
|
| MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** |
|
||||||
@@ -1250,7 +1250,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
|
|||||||
|---------|-----------------------|------------|-----------|----------|
|
|---------|-----------------------|------------|-----------|----------|
|
||||||
| **智能体** | 64 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
| **智能体** | 64 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
||||||
| **命令** | 84 | 共享 | 基于指令 | 35 |
|
| **命令** | 84 | 共享 | 基于指令 | 35 |
|
||||||
| **技能** | 261 | 共享 | 10 (原生格式) | 37 |
|
| **技能** | 262 | 共享 | 10 (原生格式) | 37 |
|
||||||
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
|
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
|
||||||
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |
|
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |
|
||||||
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |
|
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |
|
||||||
|
|||||||
@@ -0,0 +1,919 @@
|
|||||||
|
# Skill 开发指南
|
||||||
|
|
||||||
|
一份为 Everything Claude Code (ECC) 创建有效 Skill 的全面指南。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [什么是 Skill?](#什么是-skill)
|
||||||
|
- [Skill 架构](#skill-架构)
|
||||||
|
- [创建你的第一个 Skill](#创建你的第一个-skill)
|
||||||
|
- [Skill 分类](#skill-分类)
|
||||||
|
- [编写有效的 Skill 内容](#编写有效的-skill-内容)
|
||||||
|
- [最佳实践](#最佳实践)
|
||||||
|
- [常见模式](#常见模式)
|
||||||
|
- [测试你的 Skill](#测试你的-skill)
|
||||||
|
- [提交你的 Skill](#提交你的-skill)
|
||||||
|
- [示例集锦](#示例集锦)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 什么是 Skill?
|
||||||
|
|
||||||
|
Skill 是 **知识模块**,Claude Code 根据上下文自动加载。它们提供:
|
||||||
|
|
||||||
|
- **领域专业知识**:框架模式、语言习惯用法、最佳实践
|
||||||
|
- **工作流定义**:常见任务的分步流程
|
||||||
|
- **参考资料**:代码片段、检查清单、决策树
|
||||||
|
- **上下文注入**:当特定条件满足时激活
|
||||||
|
|
||||||
|
与 **Agent**(专业子助手)或 **Command**(用户触发的操作)不同,Skill 是被动知识,Claude Code 在相关时自动引用。
|
||||||
|
|
||||||
|
### Skill 何时激活
|
||||||
|
|
||||||
|
Skill 在以下情况激活:
|
||||||
|
- 用户任务与 Skill 的领域匹配
|
||||||
|
- Claude Code 检测到相关上下文
|
||||||
|
- 某个命令引用了该 Skill
|
||||||
|
- 某个 Agent 需要领域知识
|
||||||
|
|
||||||
|
### Skill vs Agent vs Command
|
||||||
|
|
||||||
|
| 组件 | 用途 | 激活方式 |
|
||||||
|
|-----------|---------|------------|
|
||||||
|
| **Skill** | 知识库 | 基于上下文(自动) |
|
||||||
|
| **Agent** | 任务执行器 | 显式委派 |
|
||||||
|
| **Command** | 用户操作 | 用户调用(`/command`) |
|
||||||
|
| **Hook** | 自动化 | 事件触发 |
|
||||||
|
| **Rule** | 始终生效的指南 | 始终激活 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Skill 架构
|
||||||
|
|
||||||
|
### 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
skills/
|
||||||
|
└── your-skill-name/
|
||||||
|
├── SKILL.md # 必需:Skill 主定义文件
|
||||||
|
├── examples/ # 可选:代码示例
|
||||||
|
│ ├── basic.ts
|
||||||
|
│ └── advanced.ts
|
||||||
|
└── references/ # 可选:外部参考
|
||||||
|
└── links.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### SKILL.md 格式
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: skill-name
|
||||||
|
description: 在 Skill 列表中显示的简要描述,用于自动激活匹配
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill 标题
|
||||||
|
|
||||||
|
简要概述此 Skill 涵盖的内容。
|
||||||
|
|
||||||
|
## 何时激活
|
||||||
|
|
||||||
|
描述 Claude 应在什么场景下使用此 Skill。
|
||||||
|
|
||||||
|
## 核心概念
|
||||||
|
|
||||||
|
主要模式和指南。
|
||||||
|
|
||||||
|
## 代码示例
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
// 实用、经过测试的示例
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 反模式
|
||||||
|
|
||||||
|
用具体示例展示不应该做的事。
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
- 可操作的指南
|
||||||
|
- 该做的和不该做的
|
||||||
|
|
||||||
|
## 相关 Skill
|
||||||
|
|
||||||
|
链接到互补的 Skill。
|
||||||
|
```
|
||||||
|
|
||||||
|
### YAML Frontmatter 字段
|
||||||
|
|
||||||
|
| 字段 | 必需 | 描述 |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `name` | 是 | 小写、连字符连接的标识符(如 `react-patterns`) |
|
||||||
|
| `description` | 是 | 单行描述,用于 Skill 列表和自动激活 |
|
||||||
|
| `origin` | 否 | 来源标识符(如 `ECC`、`community`、项目名) |
|
||||||
|
| `tags` | 否 | 分类标签数组 |
|
||||||
|
| `version` | 否 | Skill 版本号,用于跟踪更新 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 创建你的第一个 Skill
|
||||||
|
|
||||||
|
### 第1步:选择焦点
|
||||||
|
|
||||||
|
好的 Skill 是 **聚焦且可操作的**:
|
||||||
|
|
||||||
|
| 通过:好的焦点 | 不通过:太宽泛 |
|
||||||
|
|---------------|--------------|
|
||||||
|
| `react-hook-patterns` | `react` |
|
||||||
|
| `postgresql-indexing` | `databases` |
|
||||||
|
| `pytest-fixtures` | `python-testing` |
|
||||||
|
| `nextjs-app-router` | `nextjs` |
|
||||||
|
|
||||||
|
### 第2步:创建目录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p skills/your-skill-name
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第3步:编写 SKILL.md
|
||||||
|
|
||||||
|
以下是一个最小模板:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: your-skill-name
|
||||||
|
description: 简要描述何时使用此 Skill
|
||||||
|
---
|
||||||
|
|
||||||
|
# 你的 Skill 标题
|
||||||
|
|
||||||
|
简要概述(1-2句话)。
|
||||||
|
|
||||||
|
## 何时激活
|
||||||
|
|
||||||
|
- 场景1
|
||||||
|
- 场景2
|
||||||
|
- 场景3
|
||||||
|
|
||||||
|
## 核心概念
|
||||||
|
|
||||||
|
### 概念1
|
||||||
|
|
||||||
|
带示例的解释。
|
||||||
|
|
||||||
|
### 概念2
|
||||||
|
|
||||||
|
带代码的另一种模式。
|
||||||
|
|
||||||
|
## 代码示例
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
// 实用示例
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
- 做这个
|
||||||
|
- 避免那个
|
||||||
|
|
||||||
|
## 相关 Skill
|
||||||
|
|
||||||
|
- `related-skill-1`
|
||||||
|
- `related-skill-2`
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第4步:添加内容
|
||||||
|
|
||||||
|
编写 Claude 可以 **立即使用** 的内容:
|
||||||
|
|
||||||
|
- 通过:可直接复制粘贴的代码示例
|
||||||
|
- 通过:清晰的决策树
|
||||||
|
- 通过:用于验证的检查清单
|
||||||
|
- 不通过:没有示例的模糊解释
|
||||||
|
- 不通过:没有可操作指导的长篇叙述
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Skill 分类
|
||||||
|
|
||||||
|
### 语言标准
|
||||||
|
|
||||||
|
聚焦于习惯用法、命名约定和语言特定模式。
|
||||||
|
|
||||||
|
**示例:** `python-patterns`、`golang-patterns`、`typescript-standards`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: python-patterns
|
||||||
|
description: Python 习惯用法、最佳实践和模式,用于编写清晰、地道的代码。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Python 模式
|
||||||
|
|
||||||
|
## 何时激活
|
||||||
|
|
||||||
|
- 编写 Python 代码
|
||||||
|
- 重构 Python 模块
|
||||||
|
- Python 代码审查
|
||||||
|
|
||||||
|
## 核心概念
|
||||||
|
|
||||||
|
### 上下文管理器
|
||||||
|
|
||||||
|
\`\`\`python
|
||||||
|
# 始终使用上下文管理器管理资源
|
||||||
|
with open('file.txt') as f:
|
||||||
|
content = f.read()
|
||||||
|
\`\`\`
|
||||||
|
```
|
||||||
|
|
||||||
|
### 框架模式
|
||||||
|
|
||||||
|
聚焦于框架特定约定、常见模式和反模式。
|
||||||
|
|
||||||
|
**示例:** `django-patterns`、`nextjs-patterns`、`springboot-patterns`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: django-patterns
|
||||||
|
description: Django 模型、视图、URL 和模板的最佳实践。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Django 模式
|
||||||
|
|
||||||
|
## 何时激活
|
||||||
|
|
||||||
|
- 构建 Django 应用
|
||||||
|
- 创建模型和视图
|
||||||
|
- Django URL 配置
|
||||||
|
```
|
||||||
|
|
||||||
|
### 工作流 Skill
|
||||||
|
|
||||||
|
定义常见开发任务的分步流程。
|
||||||
|
|
||||||
|
**示例:** `tdd-workflow`、`code-review-workflow`、`deployment-checklist`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: code-review-workflow
|
||||||
|
description: 确保质量和安全的系统化代码审查流程。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 代码审查工作流
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
1. **理解上下文** - 阅读 PR 描述和关联 Issue
|
||||||
|
2. **检查测试** - 验证测试覆盖率和质量
|
||||||
|
3. **审查逻辑** - 分析实现的正确性
|
||||||
|
4. **检查安全** - 查找漏洞
|
||||||
|
5. **验证风格** - 确保代码遵循约定
|
||||||
|
```
|
||||||
|
|
||||||
|
### 领域知识
|
||||||
|
|
||||||
|
特定领域的专业知识(安全、性能等)。
|
||||||
|
|
||||||
|
**示例:** `security-review`、`performance-optimization`、`api-design`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: api-design
|
||||||
|
description: REST 和 GraphQL API 设计模式、版本控制和最佳实践。
|
||||||
|
---
|
||||||
|
|
||||||
|
# API 设计模式
|
||||||
|
|
||||||
|
## RESTful 约定
|
||||||
|
|
||||||
|
| 方法 | 端点 | 用途 |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| GET | /resources | 列表全部 |
|
||||||
|
| GET | /resources/:id | 获取单个 |
|
||||||
|
| POST | /resources | 创建 |
|
||||||
|
```
|
||||||
|
|
||||||
|
### 工具集成
|
||||||
|
|
||||||
|
使用特定工具、库或服务的指导。
|
||||||
|
|
||||||
|
**示例:** `supabase-patterns`、`docker-patterns`、`mcp-server-patterns`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 编写有效的 Skill 内容
|
||||||
|
|
||||||
|
### 1. 从"何时激活"开始
|
||||||
|
|
||||||
|
这一节对于自动激活 **至关重要**。要具体:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 何时激活
|
||||||
|
|
||||||
|
- 创建新的 React 组件
|
||||||
|
- 重构现有组件
|
||||||
|
- 调试 React 状态问题
|
||||||
|
- 审查 React 代码的最佳实践
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 使用"展示,而非说教"
|
||||||
|
|
||||||
|
差:
|
||||||
|
```markdown
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
在异步函数中始终正确处理错误。
|
||||||
|
```
|
||||||
|
|
||||||
|
好:
|
||||||
|
```markdown
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
async function fetchData(url: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(\`HTTP \${response.status}: \${response.statusText}\`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取失败:', error)
|
||||||
|
throw new Error('获取数据失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### 要点
|
||||||
|
|
||||||
|
- 解析前先检查 \`response.ok\`
|
||||||
|
- 记录错误以便调试
|
||||||
|
- 重新抛出错时使用用户友好的消息
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 包含反模式
|
||||||
|
|
||||||
|
展示不应该做什么:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 反模式
|
||||||
|
|
||||||
|
### 失败:直接修改状态
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
// 绝不要这样做
|
||||||
|
user.name = 'New Name'
|
||||||
|
items.push(newItem)
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### 通过:不可变更新
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
// 始终这样做
|
||||||
|
const updatedUser = { ...user, name: 'New Name' }
|
||||||
|
const updatedItems = [...items, newItem]
|
||||||
|
\`\`\`
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 提供检查清单
|
||||||
|
|
||||||
|
检查清单具有可操作性,易于遵循:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 部署前检查清单
|
||||||
|
|
||||||
|
- [ ] 所有测试通过
|
||||||
|
- [ ] 生产代码中无 console.log
|
||||||
|
- [ ] 环境变量已文档化
|
||||||
|
- [ ] 无硬编码的密钥
|
||||||
|
- [ ] 错误处理完整
|
||||||
|
- [ ] 输入验证到位
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 使用决策树
|
||||||
|
|
||||||
|
用于复杂决策:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 选择正确方案
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
需要获取数据?
|
||||||
|
├── 单次请求 → 直接使用 fetch
|
||||||
|
├── 多个独立请求 → Promise.all()
|
||||||
|
├── 多个依赖请求 → 依次 await
|
||||||
|
└── 带缓存 → 使用 SWR 或 React Query
|
||||||
|
\`\`\`
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 应该做
|
||||||
|
|
||||||
|
| 实践 | 示例 |
|
||||||
|
|----------|---------|
|
||||||
|
| **具体明确** | "对传递给子组件的事件处理函数使用 `useCallback`" |
|
||||||
|
| **展示示例** | 包含可复制粘贴的代码 |
|
||||||
|
| **解释为什么** | "不可变性防止了 React 状态中的意外副作用" |
|
||||||
|
| **链接相关 Skill** | "另见:`react-performance`" |
|
||||||
|
| **保持聚焦** | 一个 Skill = 一个领域/概念 |
|
||||||
|
| **使用章节** | 清晰的标题便于快速浏览 |
|
||||||
|
|
||||||
|
### 不应该做
|
||||||
|
|
||||||
|
| 实践 | 为什么不好 |
|
||||||
|
|----------|--------------|
|
||||||
|
| **模糊不清** | "写好代码"——不可操作 |
|
||||||
|
| **长篇叙述** | 难以解析,代码更好 |
|
||||||
|
| **覆盖过广** | "Python、Django 和 Flask 模式"——太宽泛 |
|
||||||
|
| **跳过示例** | 没有实践的理论用处不大 |
|
||||||
|
| **忽略反模式** | 学会不该做什么也很有价值 |
|
||||||
|
|
||||||
|
### 内容指南
|
||||||
|
|
||||||
|
1. **长度**:通常 200-500 行,最多 800 行
|
||||||
|
2. **代码块**:包含语言标识符
|
||||||
|
3. **标题**:使用 `##` 和 `###` 层级结构
|
||||||
|
4. **列表**:无序用 `-`,有序用 `1.`
|
||||||
|
5. **表格**:用于对比和参考
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见模式
|
||||||
|
|
||||||
|
### 模式1:标准 Skill
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: language-standards
|
||||||
|
description: [语言]的编码标准和最佳实践。
|
||||||
|
---
|
||||||
|
|
||||||
|
# [语言] 编码标准
|
||||||
|
|
||||||
|
## 何时激活
|
||||||
|
|
||||||
|
- 编写 [语言] 代码
|
||||||
|
- 代码审查
|
||||||
|
- 设置代码检查工具
|
||||||
|
|
||||||
|
## 命名约定
|
||||||
|
|
||||||
|
| 元素 | 约定 | 示例 |
|
||||||
|
|---------|------------|---------|
|
||||||
|
| 变量 | camelCase | userName |
|
||||||
|
| 常量 | SCREAMING_SNAKE | MAX_RETRY |
|
||||||
|
| 函数 | camelCase | fetchUser |
|
||||||
|
| 类 | PascalCase | UserService |
|
||||||
|
|
||||||
|
## 代码示例
|
||||||
|
|
||||||
|
[包含实用示例]
|
||||||
|
|
||||||
|
## 代码检查设置
|
||||||
|
|
||||||
|
[包含配置]
|
||||||
|
|
||||||
|
## 相关 Skill
|
||||||
|
|
||||||
|
- `language-testing`
|
||||||
|
- `language-security`
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式2:工作流 Skill
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: task-workflow
|
||||||
|
description: [任务]的分步工作流。
|
||||||
|
---
|
||||||
|
|
||||||
|
# [任务] 工作流
|
||||||
|
|
||||||
|
## 何时激活
|
||||||
|
|
||||||
|
- [触发条件1]
|
||||||
|
- [触发条件2]
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
- [要求1]
|
||||||
|
- [要求2]
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
### 步骤1:[名称]
|
||||||
|
|
||||||
|
[描述]
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
[命令]
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### 步骤2:[名称]
|
||||||
|
|
||||||
|
[描述]
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- [ ] [检查1]
|
||||||
|
- [ ] [检查2]
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
| 问题 | 解决方案 |
|
||||||
|
|---------|----------|
|
||||||
|
| [问题] | [修复] |
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式3:参考 Skill
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: api-reference
|
||||||
|
description: [API/库]的快速参考。
|
||||||
|
---
|
||||||
|
|
||||||
|
# [API/库] 参考
|
||||||
|
|
||||||
|
## 何时激活
|
||||||
|
|
||||||
|
- 使用 [API/库]
|
||||||
|
- 查阅 [API/库] 语法
|
||||||
|
|
||||||
|
## 常见操作
|
||||||
|
|
||||||
|
### 操作1
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
// 基本用法
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### 操作2
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
// 高级用法
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
[包含配置示例]
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
[包含错误模式]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试你的 Skill
|
||||||
|
|
||||||
|
### 本地测试
|
||||||
|
|
||||||
|
1. **复制到 Claude Code skills 目录**:
|
||||||
|
```bash
|
||||||
|
cp -r skills/your-skill-name ~/.claude/skills/
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **用 Claude Code 测试**:
|
||||||
|
```
|
||||||
|
你:"我需要 [应该触发你的 Skill 的任务]"
|
||||||
|
|
||||||
|
Claude 应该引用你的 Skill 的模式。
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **验证激活**:
|
||||||
|
- 让 Claude 解释你的 Skill 中的一个概念
|
||||||
|
- 检查它是否使用了你的示例和模式
|
||||||
|
- 确保它遵循了你的指南
|
||||||
|
|
||||||
|
### 验证检查清单
|
||||||
|
|
||||||
|
- [ ] **YAML frontmatter 有效** - 无语法错误
|
||||||
|
- [ ] **名称遵循约定** - 小写字母加连字符
|
||||||
|
- [ ] **描述清晰** - 告诉何时使用
|
||||||
|
- [ ] **示例有效** - 代码可以编译和运行
|
||||||
|
- [ ] **链接有效** - 相关 Skill 存在
|
||||||
|
- [ ] **无敏感数据** - 无 API 密钥、令牌、路径
|
||||||
|
|
||||||
|
### 代码示例测试
|
||||||
|
|
||||||
|
测试所有代码示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 从仓库根目录
|
||||||
|
npx tsc --noEmit skills/your-skill-name/examples/*.ts
|
||||||
|
|
||||||
|
# 或从 Skill 目录内部
|
||||||
|
npx tsc --noEmit examples/*.ts
|
||||||
|
|
||||||
|
# 从仓库根目录
|
||||||
|
python -m py_compile skills/your-skill-name/examples/*.py
|
||||||
|
|
||||||
|
# 或从 Skill 目录内部
|
||||||
|
python -m py_compile examples/*.py
|
||||||
|
|
||||||
|
# 从仓库根目录
|
||||||
|
go build ./skills/your-skill-name/examples/...
|
||||||
|
|
||||||
|
# 或从 Skill 目录内部
|
||||||
|
go build ./examples/...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 提交你的 Skill
|
||||||
|
|
||||||
|
### 1. Fork 并 Clone
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh repo fork affaan-m/everything-claude-code --clone
|
||||||
|
cd everything-claude-code
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 创建分支
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b feat/skill-your-skill-name
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 添加你的 Skill
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p skills/your-skill-name
|
||||||
|
# 创建 SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 YAML frontmatter
|
||||||
|
head -10 skills/your-skill-name/SKILL.md
|
||||||
|
|
||||||
|
# 验证结构
|
||||||
|
ls -la skills/your-skill-name/
|
||||||
|
|
||||||
|
# 如果有测试,运行测试
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 提交并推送
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add skills/your-skill-name/
|
||||||
|
git commit -m "feat(skills): add your-skill-name skill"
|
||||||
|
git push -u origin feat/skill-your-skill-name
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 创建 Pull Request
|
||||||
|
|
||||||
|
使用此 PR 模板:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
简要描述 Skill 及其价值。
|
||||||
|
|
||||||
|
## Skill Type
|
||||||
|
|
||||||
|
- [ ] 语言标准
|
||||||
|
- [ ] 框架模式
|
||||||
|
- [ ] 工作流
|
||||||
|
- [ ] 领域知识
|
||||||
|
- [ ] 工具集成
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
我是如何在本地测试此 Skill 的。
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] YAML frontmatter 有效
|
||||||
|
- [ ] 代码示例已测试
|
||||||
|
- [ ] 遵循 Skill 编写指南
|
||||||
|
- [ ] 无敏感数据
|
||||||
|
- [ ] 激活触发器清晰
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例集锦
|
||||||
|
|
||||||
|
### 示例1:语言标准
|
||||||
|
|
||||||
|
**文件:** `skills/rust-patterns/SKILL.md`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: rust-patterns
|
||||||
|
description: Rust 习惯用法、所有权模式和最佳实践,用于编写安全、地道的代码。
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# Rust 模式
|
||||||
|
|
||||||
|
## 何时激活
|
||||||
|
|
||||||
|
- 编写 Rust 代码
|
||||||
|
- 处理所有权和借用
|
||||||
|
- 使用 Result/Option 进行错误处理
|
||||||
|
- 实现 trait
|
||||||
|
|
||||||
|
## 所有权模式
|
||||||
|
|
||||||
|
### 借用规则
|
||||||
|
|
||||||
|
\`\`\`rust
|
||||||
|
// 通过:正确:当不需要所有权时使用借用
|
||||||
|
fn process_data(data: &str) -> usize {
|
||||||
|
data.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过:正确:当需要修改或消耗时获取所有权
|
||||||
|
fn consume_data(data: Vec<u8>) -> String {
|
||||||
|
String::from_utf8(data).unwrap()
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### Result 模式
|
||||||
|
|
||||||
|
\`\`\`rust
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("IO 错误: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("解析错误: {0}")]
|
||||||
|
Parse(#[from] std::num::ParseIntError),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 相关 Skill
|
||||||
|
|
||||||
|
- `rust-testing`
|
||||||
|
- `rust-security`
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例2:框架模式
|
||||||
|
|
||||||
|
**文件:** `skills/fastapi-patterns/SKILL.md`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: fastapi-patterns
|
||||||
|
description: FastAPI 路由、依赖注入、验证和异步操作的模式。
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# FastAPI 模式
|
||||||
|
|
||||||
|
## 何时激活
|
||||||
|
|
||||||
|
- 构建 FastAPI 应用
|
||||||
|
- 创建 API 端点
|
||||||
|
- 实现依赖注入
|
||||||
|
- 处理异步数据库操作
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
app/
|
||||||
|
├── main.py # FastAPI 应用入口
|
||||||
|
├── routers/ # 路由处理器
|
||||||
|
│ ├── users.py
|
||||||
|
│ └── items.py
|
||||||
|
├── models/ # Pydantic 模型
|
||||||
|
│ ├── user.py
|
||||||
|
│ └── item.py
|
||||||
|
├── services/ # 业务逻辑
|
||||||
|
│ └── user_service.py
|
||||||
|
└── dependencies.py # 共享依赖
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 依赖注入
|
||||||
|
|
||||||
|
\`\`\`python
|
||||||
|
from fastapi import Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}")
|
||||||
|
async def get_user(
|
||||||
|
user_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
# 使用 db session
|
||||||
|
pass
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 相关 Skill
|
||||||
|
|
||||||
|
- `python-patterns`
|
||||||
|
- `pydantic-validation`
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例3:工作流 Skill
|
||||||
|
|
||||||
|
**文件:** `skills/refactoring-workflow/SKILL.md`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: refactoring-workflow
|
||||||
|
description: 在不改变行为的前提下改善代码质量的系统化重构工作流。
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# 重构工作流
|
||||||
|
|
||||||
|
## 何时激活
|
||||||
|
|
||||||
|
- 改善代码结构
|
||||||
|
- 减少技术债务
|
||||||
|
- 简化复杂代码
|
||||||
|
- 提取可复用组件
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
- 所有测试通过
|
||||||
|
- Git 工作目录干净
|
||||||
|
- 已创建功能分支
|
||||||
|
|
||||||
|
## 工作流步骤
|
||||||
|
|
||||||
|
### 步骤1:确定重构目标
|
||||||
|
|
||||||
|
- 查找代码坏味道(长方法、重复代码、大类)
|
||||||
|
- 检查目标区域的测试覆盖率
|
||||||
|
- 记录当前行为
|
||||||
|
|
||||||
|
### 步骤2:确保测试存在
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# 运行测试验证当前行为
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# 检查目标文件的覆盖率
|
||||||
|
npm run test:coverage
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### 步骤3:小步修改
|
||||||
|
|
||||||
|
- 一次只做一项重构
|
||||||
|
- 每次修改后运行测试
|
||||||
|
- 频繁提交
|
||||||
|
|
||||||
|
### 步骤4:验证行为未变
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# 运行完整测试套件
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# 运行 E2E 测试
|
||||||
|
npm run test:e2e
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 常见重构
|
||||||
|
|
||||||
|
| 坏味道 | 重构方法 |
|
||||||
|
|-------|-------------|
|
||||||
|
| 长方法 | 提取方法 |
|
||||||
|
| 重复代码 | 提取为共享函数 |
|
||||||
|
| 大类 | 提取类 |
|
||||||
|
| 长参数列表 | 引入参数对象 |
|
||||||
|
|
||||||
|
## 检查清单
|
||||||
|
|
||||||
|
- [ ] 目标代码有测试覆盖
|
||||||
|
- [ ] 进行了小步、聚焦的修改
|
||||||
|
- [ ] 每次修改后测试通过
|
||||||
|
- [ ] 行为未改变
|
||||||
|
- [ ] 使用清晰的消息提交
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 其他资源
|
||||||
|
|
||||||
|
- [CONTRIBUTING.md](CONTRIBUTING.md) - 通用贡献指南
|
||||||
|
- [project-guidelines-template](../examples/project-guidelines-template.md) - 项目专属 Skill 模板
|
||||||
|
- [coding-standards](../../skills/coding-standards/SKILL.md) - 标准 Skill 示例
|
||||||
|
- [tdd-workflow](../../skills/tdd-workflow/SKILL.md) - 工作流 Skill 示例
|
||||||
|
- [security-review](../../skills/security-review/SKILL.md) - 领域知识 Skill 示例
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**记住**:好的 Skill 是聚焦的、可操作的、立即可用的。写你自己也想用的 Skill。
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
---
|
||||||
|
name: ecc-guide
|
||||||
|
description: 在回答之前先读取仓库的实时状态,引导用户了解 ECC 当前的 agents、skills、命令、hooks、规则、安装配置档案以及项目接入流程。
|
||||||
|
origin: community
|
||||||
|
---
|
||||||
|
|
||||||
|
# ECC 指南
|
||||||
|
|
||||||
|
当用户需要帮助来理解、浏览、安装 Everything Claude Code 或在其中做选择时,使用此技能。
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
当用户出现以下情况时使用此技能:
|
||||||
|
|
||||||
|
- 询问 ECC 包含哪些内容
|
||||||
|
- 需要帮助查找某个 skill、命令、agent、hook、规则或安装配置档案
|
||||||
|
- 刚接触本仓库,需要一条引导路径
|
||||||
|
- 询问"如何用 ECC 做 X?"
|
||||||
|
- 询问哪些 ECC 组件适合某个项目
|
||||||
|
- 需要简单了解命令、skills、agents、hooks 和规则之间的关系
|
||||||
|
- 对安装路径、重复安装、重置/卸载或选择性安装选项感到困惑
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
依据当前文件回答,而不是凭记忆。ECC 变化很快,硬编码的目录数量、功能列表和安装说明都会过时。
|
||||||
|
|
||||||
|
当 ECC 仓库可用时,先检查相关文件再给出具体答案:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/ci/catalog.js --json
|
||||||
|
find skills -maxdepth 2 -name SKILL.md | sort
|
||||||
|
find commands -maxdepth 1 -name '*.md' | sort
|
||||||
|
find agents -maxdepth 1 -name '*.md' | sort
|
||||||
|
node scripts/install-plan.js --list-profiles
|
||||||
|
node scripts/install-plan.js --list-components --json
|
||||||
|
```
|
||||||
|
|
||||||
|
只读取回答用户问题所需的最小文件集。
|
||||||
|
|
||||||
|
## 仓库地图
|
||||||
|
|
||||||
|
- `README.md`:安装路径、卸载/重置指引、对外定位、常见问题
|
||||||
|
- `AGENTS.md`:贡献者指引和项目结构
|
||||||
|
- `agent.yaml`:导出的 gitagent 接口和命令列表
|
||||||
|
- `commands/`:持续维护的斜杠命令兼容垫片
|
||||||
|
- `skills/*/SKILL.md`:可复用的工作流和领域手册
|
||||||
|
- `agents/*.md`:用于委派的子代理角色提示词
|
||||||
|
- `rules/`:语言规则和运行环境规则
|
||||||
|
- `hooks/README.md`、`hooks/hooks.json`、`scripts/hooks/`:hook 行为和安全门控
|
||||||
|
- `manifests/install-*.json`:选择性安装的模块、组件、配置档案和目标支持
|
||||||
|
- `docs/`:运行环境指南、架构笔记、翻译文档、发布文档
|
||||||
|
|
||||||
|
## 回复风格
|
||||||
|
|
||||||
|
先给答案,再给下一步动作。大多数用户不需要完整的目录倾倒。
|
||||||
|
|
||||||
|
良好的首次回复结构:
|
||||||
|
|
||||||
|
1. 用什么
|
||||||
|
2. 为什么合适
|
||||||
|
3. 要查看的确切文件或命令
|
||||||
|
4. 一个后续命令或问题
|
||||||
|
|
||||||
|
避免:
|
||||||
|
|
||||||
|
- 默认列出所有 skill 或命令
|
||||||
|
- 重复 README 的大段内容
|
||||||
|
- 在已有 skill 优先路径时仍推荐已退役的命令垫片
|
||||||
|
- 未检查文件系统就声称某个组件存在
|
||||||
|
- 在托管安装器支持目标环境时,用手动复制命令代替安装指引
|
||||||
|
|
||||||
|
## 常见任务
|
||||||
|
|
||||||
|
### 新用户入门
|
||||||
|
|
||||||
|
给出一份简短菜单:
|
||||||
|
|
||||||
|
- 安装或重置 ECC
|
||||||
|
- 为项目挑选 skills
|
||||||
|
- 理解命令与 skills 的区别
|
||||||
|
- 检查 hooks 和安全行为
|
||||||
|
- 运行一次运行环境审计
|
||||||
|
- 查找某个特定工作流
|
||||||
|
|
||||||
|
安装/重置指向 `README.md`,项目级接入指向 `/project-init`。
|
||||||
|
|
||||||
|
### 功能发现
|
||||||
|
|
||||||
|
对于"我该用什么来做 X?":
|
||||||
|
|
||||||
|
1. 搜索 `skills/`、`commands/` 和 `agents/`。
|
||||||
|
2. 优先把 skills 作为主要工作流入口。
|
||||||
|
3. 仅当命令是持续维护的兼容垫片、或用户明确想要斜杠命令行为时才使用命令。
|
||||||
|
4. 当委派有价值时提及 agents。
|
||||||
|
|
||||||
|
有用的搜索:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "<query>" skills commands agents docs
|
||||||
|
find skills -maxdepth 2 -name SKILL.md | sort
|
||||||
|
```
|
||||||
|
|
||||||
|
### 安装指引
|
||||||
|
|
||||||
|
使用托管安装路径:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/install-plan.js --list-profiles
|
||||||
|
node scripts/install-plan.js --profile minimal --target claude --json
|
||||||
|
node scripts/install-apply.js --profile minimal --target claude --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
针对特定 skill 的安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/install-plan.js --skills <skill-id> --target claude --json
|
||||||
|
node scripts/install-apply.js --skills <skill-id> --target claude --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
提醒用户不要同时叠加插件安装和完整的手动/档案安装,除非他们有意要重复的组件面。
|
||||||
|
|
||||||
|
### 项目接入
|
||||||
|
|
||||||
|
当用户想为目标仓库配置 ECC 时,使用 `/project-init`。预期顺序为:
|
||||||
|
|
||||||
|
1. 从项目文件检测技术栈
|
||||||
|
2. 生成一份 dry-run 安装计划
|
||||||
|
3. 检查现有的 `CLAUDE.md` 和设置文件
|
||||||
|
4. 在应用更改前先询问
|
||||||
|
5. 保持生成的指引精简且针对该仓库
|
||||||
|
|
||||||
|
### 故障排查
|
||||||
|
|
||||||
|
先询问目标运行环境和安装路径,然后检查:
|
||||||
|
|
||||||
|
- 插件安装元数据
|
||||||
|
- `.claude/`、`.cursor/`、`.codex/`、`.gemini/`、`.opencode/`、`.codebuddy/`、`.joycode/` 或 `.qwen/`
|
||||||
|
- `hooks/hooks.json`
|
||||||
|
- 安装状态文件
|
||||||
|
- 相关的命令/skill 文件
|
||||||
|
|
||||||
|
针对仓库健康度,建议:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run harness:audit -- --format text
|
||||||
|
npm run observability:ready
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 输出模板
|
||||||
|
|
||||||
|
### 简短推荐
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use <skill-or-command>. It fits because <reason>.
|
||||||
|
|
||||||
|
Canonical file: <path>
|
||||||
|
Verify with: <command>
|
||||||
|
Next: <one concrete action>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 搜索结果
|
||||||
|
|
||||||
|
```text
|
||||||
|
Best matches:
|
||||||
|
- <path>: <why it matters>
|
||||||
|
- <path>: <why it matters>
|
||||||
|
|
||||||
|
Recommendation: <which one to use first and why>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 安装计划摘要
|
||||||
|
|
||||||
|
```text
|
||||||
|
Detected: <stack evidence>
|
||||||
|
Target: <harness>
|
||||||
|
Plan: <profile/modules/skills>
|
||||||
|
Dry run: <command>
|
||||||
|
Would change: <paths>
|
||||||
|
Needs approval before apply: <yes/no>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 相关入口
|
||||||
|
|
||||||
|
- `/project-init`:面向目标仓库的技术栈感知接入计划
|
||||||
|
- `/harness-audit`:确定性的就绪度评分卡
|
||||||
|
- `/skill-health`:skill 质量审查
|
||||||
|
- `/skill-create`:从本地 git 历史生成新 skill
|
||||||
|
- `/security-scan`:检查 Claude/OpenCode 配置安全性
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
name: parallel-execution-optimizer
|
||||||
|
description: 当用户希望通过并行工作、并发 agents、批量工具调用、隔离 worktree 或多条独立验证通道来大幅加速任务、同时不损失正确性时使用。
|
||||||
|
origin: ECC
|
||||||
|
tools: Read, Write, Edit, Bash, Grep, Glob
|
||||||
|
---
|
||||||
|
|
||||||
|
# 并行执行优化器
|
||||||
|
|
||||||
|
当速度来自同时处理相互独立的工作时,使用此技能:
|
||||||
|
仓库巡检、文件读取、API 检查、浏览器检查、构建/测试通道、
|
||||||
|
部署回读,或多 worktree 的实现批次。
|
||||||
|
|
||||||
|
## 核心模式
|
||||||
|
|
||||||
|
行动之前,先把紧迫感转化为依赖图。
|
||||||
|
|
||||||
|
1. 定义目标和完成信号。
|
||||||
|
2. 把工作拆分成通道(lane)。
|
||||||
|
3. 给每条通道标注执行方式:并行、串行或门控。
|
||||||
|
4. 把相互独立的读取/检查放在一起执行。
|
||||||
|
5. 让写入按文件、worktree、分支、服务或数据集相互隔离。
|
||||||
|
6. 只有在证据表明各通道相互兼容后才合并。
|
||||||
|
7. 以一张验证表收尾,而不是一句模糊的"变快了"。
|
||||||
|
|
||||||
|
## 通道矩阵
|
||||||
|
|
||||||
|
在大规模推进之前,写一张紧凑的矩阵:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Lane | Can run in parallel? | Write surface | Risk | Verification
|
||||||
|
Repo scan | yes | none | low | rg/git status outputs
|
||||||
|
Backend patch | maybe | src/api | medium | unit tests
|
||||||
|
Frontend patch | maybe | app/components | medium | browser screenshot
|
||||||
|
Deploy readback | after build | remote service | high | live URL + logs
|
||||||
|
```
|
||||||
|
|
||||||
|
只有当各通道的写入面互不冲突时,才并行运行。
|
||||||
|
|
||||||
|
## 执行规则
|
||||||
|
|
||||||
|
- 把文件读取、搜索、状态检查和元数据查询批量化。
|
||||||
|
- 对大型且互不相关的实现通道使用隔离的 worktree。
|
||||||
|
- 长时间运行的测试、构建、回填和部署放到独立会话中启动,
|
||||||
|
然后有节奏地主动轮询。
|
||||||
|
- 如果某条通道发现了会改变计划的阻塞点,暂停依赖它的通道
|
||||||
|
并更新矩阵。
|
||||||
|
- 除非用户明确要求持续运行的服务,绝不让后台进程存活超过本轮。
|
||||||
|
- 没有明确门控时,不要并行执行破坏性命令、数据迁移、对同一张表的写入,
|
||||||
|
或影响线上客户的部署。
|
||||||
|
|
||||||
|
## 输出形态
|
||||||
|
|
||||||
|
汇报时使用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Parallel execution result:
|
||||||
|
- Lanes run: 5
|
||||||
|
- Lanes completed: 4
|
||||||
|
- Blocked lane: deploy readback, waiting on DNS propagation
|
||||||
|
- Fast path found: batched repo scan + focused tests
|
||||||
|
- Verification: lint pass, unit pass, live smoke pass
|
||||||
|
```
|
||||||
|
|
||||||
|
## 失败模式
|
||||||
|
|
||||||
|
- 更多并发反而制造了相互冲突的编辑。
|
||||||
|
- 在给工具跑分,而不是在完成任务。
|
||||||
|
- 在正确性得到证明之前就把"快"当成"做完了"。
|
||||||
|
- 忘记轮询正在运行的会话。
|
||||||
|
- 用一句成功摘要掩盖被跳过的检查。
|
||||||
Generated
+191
-122
@@ -8,18 +8,6 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ahash"
|
|
||||||
version = "0.8.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"once_cell",
|
|
||||||
"version_check",
|
|
||||||
"zerocopy",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -166,6 +154,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.2"
|
version = "3.20.2"
|
||||||
@@ -297,6 +294,12 @@ dependencies = [
|
|||||||
"static_assertions",
|
"static_assertions",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@@ -306,6 +309,35 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
"time",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie_store"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||||
|
dependencies = [
|
||||||
|
"cookie",
|
||||||
|
"document-features",
|
||||||
|
"idna",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"time",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
@@ -321,6 +353,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -347,22 +388,6 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crossterm"
|
|
||||||
version = "0.28.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.0",
|
|
||||||
"crossterm_winapi",
|
|
||||||
"mio",
|
|
||||||
"parking_lot",
|
|
||||||
"rustix 0.38.44",
|
|
||||||
"signal-hook",
|
|
||||||
"signal-hook-mio",
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossterm"
|
name = "crossterm"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -375,7 +400,7 @@ dependencies = [
|
|||||||
"document-features",
|
"document-features",
|
||||||
"mio",
|
"mio",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rustix 1.1.4",
|
"rustix",
|
||||||
"signal-hook",
|
"signal-hook",
|
||||||
"signal-hook-mio",
|
"signal-hook-mio",
|
||||||
"winapi",
|
"winapi",
|
||||||
@@ -400,6 +425,15 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "csscolorparser"
|
name = "csscolorparser"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -487,8 +521,19 @@ version = "0.10.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer 0.10.4",
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer 0.12.0",
|
||||||
|
"const-oid",
|
||||||
|
"crypto-common 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -540,7 +585,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"cron",
|
"cron",
|
||||||
"crossterm 0.28.1",
|
"crossterm",
|
||||||
"dirs",
|
"dirs",
|
||||||
"git2",
|
"git2",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -549,7 +594,7 @@ dependencies = [
|
|||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.11.0",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
@@ -745,15 +790,6 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.14.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
|
||||||
dependencies = [
|
|
||||||
"ahash",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -787,11 +823,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashlink"
|
name = "hashlink"
|
||||||
version = "0.9.1"
|
version = "0.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.14.5",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -806,6 +842,31 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httparse"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iana-time-zone"
|
name = "iana-time-zone"
|
||||||
version = "0.1.65"
|
version = "0.1.65"
|
||||||
@@ -1085,9 +1146,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libsqlite3-sys"
|
name = "libsqlite3-sys"
|
||||||
version = "0.30.1"
|
version = "0.38.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
@@ -1129,12 +1190,6 @@ dependencies = [
|
|||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "linux-raw-sys"
|
|
||||||
version = "0.4.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -1460,7 +1515,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
|
checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pest",
|
"pest",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1648,8 +1703,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c"
|
checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"crossterm 0.28.1",
|
"crossterm",
|
||||||
"crossterm 0.29.0",
|
|
||||||
"instability",
|
"instability",
|
||||||
"ratatui-core",
|
"ratatui-core",
|
||||||
]
|
]
|
||||||
@@ -1758,10 +1812,20 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rsqlite-vfs"
|
||||||
version = "0.32.1"
|
version = "0.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.16.1",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.40.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"fallible-iterator",
|
"fallible-iterator",
|
||||||
@@ -1769,6 +1833,7 @@ dependencies = [
|
|||||||
"hashlink",
|
"hashlink",
|
||||||
"libsqlite3-sys",
|
"libsqlite3-sys",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
|
"sqlite-wasm-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1780,19 +1845,6 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustix"
|
|
||||||
version = "0.38.44"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.0",
|
|
||||||
"errno",
|
|
||||||
"libc",
|
|
||||||
"linux-raw-sys 0.4.15",
|
|
||||||
"windows-sys 0.59.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -1802,7 +1854,7 @@ dependencies = [
|
|||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1924,8 +1976,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2002,6 +2065,18 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlite-wasm-rs"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"js-sys",
|
||||||
|
"rsqlite-vfs",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -2126,7 +2201,7 @@ dependencies = [
|
|||||||
"pest",
|
"pest",
|
||||||
"pest_derive",
|
"pest_derive",
|
||||||
"phf",
|
"phf",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"signal-hook",
|
"signal-hook",
|
||||||
"siphasher",
|
"siphasher",
|
||||||
"terminfo",
|
"terminfo",
|
||||||
@@ -2199,12 +2274,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
|
"itoa",
|
||||||
"libc",
|
"libc",
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"num_threads",
|
"num_threads",
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"time-core",
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2213,6 +2290,16 @@ version = "0.1.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-macros"
|
||||||
|
version = "0.2.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||||
|
dependencies = [
|
||||||
|
"num-conv",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -2355,9 +2442,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.19.0"
|
version = "1.20.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ucd-trie"
|
name = "ucd-trie"
|
||||||
@@ -2408,20 +2495,34 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ureq"
|
name = "ureq"
|
||||||
version = "2.12.1"
|
version = "3.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
|
"cookie_store",
|
||||||
"flate2",
|
"flate2",
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"percent-encoding",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"url",
|
"ureq-proto",
|
||||||
"webpki-roots 0.26.11",
|
"utf8-zero",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq-proto"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2436,6 +2537,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8-zero"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -2590,15 +2697,6 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "webpki-roots"
|
|
||||||
version = "0.26.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
|
||||||
dependencies = [
|
|
||||||
"webpki-roots 1.0.6",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -2626,7 +2724,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"mac_address",
|
"mac_address",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -2770,15 +2868,6 @@ dependencies = [
|
|||||||
"windows-targets",
|
"windows-targets",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-sys"
|
|
||||||
version = "0.59.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
|
||||||
dependencies = [
|
|
||||||
"windows-targets",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -2978,26 +3067,6 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerocopy"
|
|
||||||
version = "0.8.47"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
|
|
||||||
dependencies = [
|
|
||||||
"zerocopy-derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerocopy-derive"
|
|
||||||
version = "0.8.47"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
|
|||||||
+5
-5
@@ -13,14 +13,14 @@ vendored-openssl = ["git2/vendored-openssl"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# TUI
|
# TUI
|
||||||
ratatui = { version = "0.30", features = ["crossterm_0_28"] }
|
ratatui = { version = "0.30", features = ["crossterm_0_29"] }
|
||||||
crossterm = "0.28"
|
crossterm = "0.29"
|
||||||
|
|
||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
# State store
|
# State store
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
|
|
||||||
# Git integration
|
# Git integration
|
||||||
git2 = { version = "0.20", features = ["ssh"] }
|
git2 = { version = "0.20", features = ["ssh"] }
|
||||||
@@ -30,8 +30,8 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.11"
|
||||||
ureq = { version = "2", features = ["json"] }
|
ureq = { version = "3", features = ["json"] }
|
||||||
|
|
||||||
# CLI
|
# CLI
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[toolchain]
|
||||||
|
# Minimum 1.85 required: several dependencies use edition2024.
|
||||||
|
channel = "1.96"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
@@ -403,16 +403,17 @@ fn run_notification_command(_program: &str, _args: &[String]) -> Result<()> {
|
|||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
fn send_webhook_request(target: &WebhookTarget, payload: serde_json::Value) -> Result<()> {
|
fn send_webhook_request(target: &WebhookTarget, payload: serde_json::Value) -> Result<()> {
|
||||||
let agent = ureq::AgentBuilder::new()
|
let agent = ureq::Agent::config_builder()
|
||||||
.timeout_connect(std::time::Duration::from_secs(5))
|
.timeout_connect(Some(std::time::Duration::from_secs(5)))
|
||||||
.timeout_read(std::time::Duration::from_secs(5))
|
.timeout_recv_response(Some(std::time::Duration::from_secs(5)))
|
||||||
.build();
|
.build()
|
||||||
|
.new_agent();
|
||||||
let response = agent
|
let response = agent
|
||||||
.post(&target.url)
|
.post(&target.url)
|
||||||
.send_json(payload)
|
.send_json(payload)
|
||||||
.with_context(|| format!("POST {}", target.url))?;
|
.with_context(|| format!("POST {}", target.url))?;
|
||||||
|
|
||||||
if response.status() >= 200 && response.status() < 300 {
|
if response.status().is_success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
anyhow::bail!("{} returned {}", target.url, response.status());
|
anyhow::bail!("{} returned {}", target.url, response.status());
|
||||||
|
|||||||
@@ -5043,6 +5043,9 @@ mod tests {
|
|||||||
run_git(path, ["init", "-q"])?;
|
run_git(path, ["init", "-q"])?;
|
||||||
run_git(path, ["config", "user.name", "ECC Tests"])?;
|
run_git(path, ["config", "user.name", "ECC Tests"])?;
|
||||||
run_git(path, ["config", "user.email", "ecc-tests@example.com"])?;
|
run_git(path, ["config", "user.email", "ecc-tests@example.com"])?;
|
||||||
|
// Keep fixtures hermetic: a global core.hooksPath (e.g. identity-checking
|
||||||
|
// pre-push hooks) must not run inside test repos.
|
||||||
|
run_git(path, ["config", "core.hooksPath", "hooks-disabled"])?;
|
||||||
fs::write(path.join("README.md"), "hello\n")?;
|
fs::write(path.join("README.md"), "hello\n")?;
|
||||||
run_git(path, ["add", "README.md"])?;
|
run_git(path, ["add", "README.md"])?;
|
||||||
run_git(path, ["commit", "-qm", "init"])?;
|
run_git(path, ["commit", "-qm", "init"])?;
|
||||||
|
|||||||
+30
-26
@@ -1025,7 +1025,7 @@ impl StateStore {
|
|||||||
profile.permission_mode,
|
profile.permission_mode,
|
||||||
add_dirs_json,
|
add_dirs_json,
|
||||||
profile.max_budget_usd,
|
profile.max_budget_usd,
|
||||||
profile.token_budget,
|
profile.token_budget.map(|tokens| tokens as i64),
|
||||||
profile.append_system_prompt,
|
profile.append_system_prompt,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
@@ -1062,7 +1062,9 @@ impl StateStore {
|
|||||||
permission_mode: row.get(4)?,
|
permission_mode: row.get(4)?,
|
||||||
add_dirs: serde_json::from_str(&add_dirs_json).unwrap_or_default(),
|
add_dirs: serde_json::from_str(&add_dirs_json).unwrap_or_default(),
|
||||||
max_budget_usd: row.get(6)?,
|
max_budget_usd: row.get(6)?,
|
||||||
token_budget: row.get(7)?,
|
token_budget: row
|
||||||
|
.get::<_, Option<i64>>(7)?
|
||||||
|
.map(|tokens| tokens as u64),
|
||||||
append_system_prompt: row.get(8)?,
|
append_system_prompt: row.get(8)?,
|
||||||
agent: None,
|
agent: None,
|
||||||
})
|
})
|
||||||
@@ -1568,12 +1570,12 @@ impl StateStore {
|
|||||||
updated_at = ?8
|
updated_at = ?8
|
||||||
WHERE id = ?9",
|
WHERE id = ?9",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
metrics.input_tokens,
|
metrics.input_tokens as i64,
|
||||||
metrics.output_tokens,
|
metrics.output_tokens as i64,
|
||||||
metrics.tokens_used,
|
metrics.tokens_used as i64,
|
||||||
metrics.tool_calls,
|
metrics.tool_calls as i64,
|
||||||
metrics.files_changed,
|
metrics.files_changed,
|
||||||
metrics.duration_secs,
|
metrics.duration_secs as i64,
|
||||||
metrics.cost_usd,
|
metrics.cost_usd,
|
||||||
chrono::Utc::now().to_rfc3339(),
|
chrono::Utc::now().to_rfc3339(),
|
||||||
session_id,
|
session_id,
|
||||||
@@ -1596,7 +1598,7 @@ impl StateStore {
|
|||||||
row.get::<_, String>(1)?,
|
row.get::<_, String>(1)?,
|
||||||
row.get::<_, String>(2)?,
|
row.get::<_, String>(2)?,
|
||||||
row.get::<_, String>(3)?,
|
row.get::<_, String>(3)?,
|
||||||
row.get::<_, u64>(4)?,
|
row.get::<_, i64>(4)? as u64,
|
||||||
))
|
))
|
||||||
})?
|
})?
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
@@ -1626,7 +1628,7 @@ impl StateStore {
|
|||||||
if duration_secs != current_duration {
|
if duration_secs != current_duration {
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"UPDATE sessions SET duration_secs = ?1 WHERE id = ?2",
|
"UPDATE sessions SET duration_secs = ?1 WHERE id = ?2",
|
||||||
rusqlite::params![duration_secs, session_id],
|
rusqlite::params![duration_secs as i64, session_id],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1706,11 +1708,11 @@ impl StateStore {
|
|||||||
cost_usd = ?4
|
cost_usd = ?4
|
||||||
WHERE id = ?5",
|
WHERE id = ?5",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
aggregate.input_tokens,
|
aggregate.input_tokens as i64,
|
||||||
aggregate.output_tokens,
|
aggregate.output_tokens as i64,
|
||||||
aggregate
|
aggregate
|
||||||
.input_tokens
|
.input_tokens
|
||||||
.saturating_add(aggregate.output_tokens),
|
.saturating_add(aggregate.output_tokens) as i64,
|
||||||
aggregate.cost_usd,
|
aggregate.cost_usd,
|
||||||
session_id,
|
session_id,
|
||||||
],
|
],
|
||||||
@@ -1871,7 +1873,7 @@ impl StateStore {
|
|||||||
row.input_params_json,
|
row.input_params_json,
|
||||||
row.output_summary,
|
row.output_summary,
|
||||||
trigger_summary,
|
trigger_summary,
|
||||||
row.duration_ms,
|
row.duration_ms as i64,
|
||||||
risk_score,
|
risk_score,
|
||||||
timestamp,
|
timestamp,
|
||||||
file_paths_json,
|
file_paths_json,
|
||||||
@@ -2135,12 +2137,12 @@ impl StateStore {
|
|||||||
})
|
})
|
||||||
.with_timezone(&chrono::Utc),
|
.with_timezone(&chrono::Utc),
|
||||||
metrics: SessionMetrics {
|
metrics: SessionMetrics {
|
||||||
input_tokens: row.get(11)?,
|
input_tokens: row.get::<_, i64>(11)? as u64,
|
||||||
output_tokens: row.get(12)?,
|
output_tokens: row.get::<_, i64>(12)? as u64,
|
||||||
tokens_used: row.get(13)?,
|
tokens_used: row.get::<_, i64>(13)? as u64,
|
||||||
tool_calls: row.get(14)?,
|
tool_calls: row.get::<_, i64>(14)? as u64,
|
||||||
files_changed: row.get(15)?,
|
files_changed: row.get(15)?,
|
||||||
duration_secs: row.get(16)?,
|
duration_secs: row.get::<_, i64>(16)? as u64,
|
||||||
cost_usd: row.get(17)?,
|
cost_usd: row.get(17)?,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -3813,7 +3815,7 @@ impl StateStore {
|
|||||||
input_params_json,
|
input_params_json,
|
||||||
output_summary,
|
output_summary,
|
||||||
trigger_summary,
|
trigger_summary,
|
||||||
duration_ms,
|
duration_ms as i64,
|
||||||
risk_score,
|
risk_score,
|
||||||
timestamp,
|
timestamp,
|
||||||
],
|
],
|
||||||
@@ -3842,11 +3844,11 @@ impl StateStore {
|
|||||||
let page = page.max(1);
|
let page = page.max(1);
|
||||||
let offset = (page - 1) * page_size;
|
let offset = (page - 1) * page_size;
|
||||||
|
|
||||||
let total: u64 = self.conn.query_row(
|
let total = self.conn.query_row(
|
||||||
"SELECT COUNT(*) FROM tool_log WHERE session_id = ?1",
|
"SELECT COUNT(*) FROM tool_log WHERE session_id = ?1",
|
||||||
rusqlite::params![session_id],
|
rusqlite::params![session_id],
|
||||||
|row| row.get(0),
|
|row| row.get::<_, i64>(0),
|
||||||
)?;
|
)? as u64;
|
||||||
|
|
||||||
let mut stmt = self.conn.prepare(
|
let mut stmt = self.conn.prepare(
|
||||||
"SELECT id, session_id, tool_name, input_summary, input_params_json, output_summary, trigger_summary, duration_ms, risk_score, timestamp
|
"SELECT id, session_id, tool_name, input_summary, input_params_json, output_summary, trigger_summary, duration_ms, risk_score, timestamp
|
||||||
@@ -3857,7 +3859,9 @@ impl StateStore {
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
let entries = stmt
|
let entries = stmt
|
||||||
.query_map(rusqlite::params![session_id, page_size, offset], |row| {
|
.query_map(
|
||||||
|
rusqlite::params![session_id, page_size as i64, offset as i64],
|
||||||
|
|row| {
|
||||||
Ok(ToolLogEntry {
|
Ok(ToolLogEntry {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
session_id: row.get(1)?,
|
session_id: row.get(1)?,
|
||||||
@@ -3868,7 +3872,7 @@ impl StateStore {
|
|||||||
.unwrap_or_else(|| "{}".to_string()),
|
.unwrap_or_else(|| "{}".to_string()),
|
||||||
output_summary: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
|
output_summary: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
|
||||||
trigger_summary: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
|
trigger_summary: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
|
||||||
duration_ms: row.get::<_, Option<u64>>(7)?.unwrap_or_default(),
|
duration_ms: row.get::<_, Option<i64>>(7)?.unwrap_or_default() as u64,
|
||||||
risk_score: row.get::<_, Option<f64>>(8)?.unwrap_or_default(),
|
risk_score: row.get::<_, Option<f64>>(8)?.unwrap_or_default(),
|
||||||
timestamp: row.get(9)?,
|
timestamp: row.get(9)?,
|
||||||
})
|
})
|
||||||
@@ -3903,7 +3907,7 @@ impl StateStore {
|
|||||||
.unwrap_or_else(|| "{}".to_string()),
|
.unwrap_or_else(|| "{}".to_string()),
|
||||||
output_summary: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
|
output_summary: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
|
||||||
trigger_summary: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
|
trigger_summary: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
|
||||||
duration_ms: row.get::<_, Option<u64>>(7)?.unwrap_or_default(),
|
duration_ms: row.get::<_, Option<i64>>(7)?.unwrap_or_default() as u64,
|
||||||
risk_score: row.get::<_, Option<f64>>(8)?.unwrap_or_default(),
|
risk_score: row.get::<_, Option<f64>>(8)?.unwrap_or_default(),
|
||||||
timestamp: row.get(9)?,
|
timestamp: row.get(9)?,
|
||||||
})
|
})
|
||||||
@@ -6629,7 +6633,7 @@ mod tests {
|
|||||||
"{}",
|
"{}",
|
||||||
"updated file",
|
"updated file",
|
||||||
"context graph",
|
"context graph",
|
||||||
0u64,
|
0i64,
|
||||||
0.0f64,
|
0.0f64,
|
||||||
"2026-04-10T00:01:00Z",
|
"2026-04-10T00:01:00Z",
|
||||||
"[\"src/backfill.rs\"]",
|
"[\"src/backfill.rs\"]",
|
||||||
|
|||||||
@@ -15047,6 +15047,9 @@ diff --git a/src/lib.rs b/src/lib.rs
|
|||||||
run_git(path, &["init", "-q"])?;
|
run_git(path, &["init", "-q"])?;
|
||||||
run_git(path, &["config", "user.name", "ECC Tests"])?;
|
run_git(path, &["config", "user.name", "ECC Tests"])?;
|
||||||
run_git(path, &["config", "user.email", "ecc-tests@example.com"])?;
|
run_git(path, &["config", "user.email", "ecc-tests@example.com"])?;
|
||||||
|
// Keep fixtures hermetic: a global core.hooksPath (e.g. identity-checking
|
||||||
|
// pre-push hooks) must not run inside test repos.
|
||||||
|
run_git(path, &["config", "core.hooksPath", "hooks-disabled"])?;
|
||||||
fs::write(path.join("README.md"), "hello\n")?;
|
fs::write(path.join("README.md"), "hello\n")?;
|
||||||
run_git(path, &["add", "README.md"])?;
|
run_git(path, &["add", "README.md"])?;
|
||||||
run_git(path, &["commit", "-qm", "init"])?;
|
run_git(path, &["commit", "-qm", "init"])?;
|
||||||
|
|||||||
@@ -1356,7 +1356,12 @@ fn dependency_fingerprint(root: &Path, files: &[&str]) -> Result<String> {
|
|||||||
hasher.update(&content);
|
hasher.update(&content);
|
||||||
hasher.update([0xff]);
|
hasher.update([0xff]);
|
||||||
}
|
}
|
||||||
Ok(format!("{:x}", hasher.finalize()))
|
// sha2 0.11 output arrays no longer implement LowerHex; hex-encode manually.
|
||||||
|
Ok(hasher
|
||||||
|
.finalize()
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_symlink_to(path: &Path, target: &Path) -> Result<bool> {
|
fn is_symlink_to(path: &Path, target: &Path) -> Result<bool> {
|
||||||
|
|||||||
Generated
+304
-18
@@ -21,8 +21,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@opencode-ai/plugin": "^1.0.0",
|
"@opencode-ai/plugin": "^1.16.2",
|
||||||
"@types/node": "25.7.0",
|
"@types/node": "25.9.2",
|
||||||
"c8": "^11.0.0",
|
"c8": "^11.0.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
@@ -320,39 +320,135 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@opencode-ai/plugin": {
|
"node_modules/@opencode-ai/plugin": {
|
||||||
"version": "1.3.15",
|
"version": "1.17.3",
|
||||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.17.3.tgz",
|
||||||
"integrity": "sha512-jZJbuvUXc5Limz8pacQl+ffATjjKGlq+xaA4wTUeW+/spwOf7Yr5Ryyvan8eNlYM8wy6h5SLfznl1rlFpjYC8w==",
|
"integrity": "sha512-Qz1ADiWxxXwuetXs6FE2T0kQmPXM6F8XDXE73SdC/oBZFYg7Oc1nf74GaEGhrvqQSMYm4kR6dHNF2jPVKn4eFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "1.3.15",
|
"@opencode-ai/sdk": "1.17.3",
|
||||||
|
"effect": "4.0.0-beta.74",
|
||||||
"zod": "4.1.8"
|
"zod": "4.1.8"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.1.96",
|
"@opentui/core": ">=0.3.4",
|
||||||
"@opentui/solid": ">=0.1.96"
|
"@opentui/keymap": ">=0.3.4",
|
||||||
|
"@opentui/solid": ">=0.3.4"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@opentui/core": {
|
"@opentui/core": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"@opentui/keymap": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"@opentui/solid": {
|
"@opentui/solid": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opencode-ai/sdk": {
|
"node_modules/@opencode-ai/sdk": {
|
||||||
"version": "1.3.15",
|
"version": "1.17.3",
|
||||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.3.tgz",
|
||||||
"integrity": "sha512-Uk59C7wsK20wpdr277yx7Xz7TqG5jGqlZUpSW3wDH/7a2K2iBg0lXc2wskHuCXLRXMhXpPZtb4a3SOpPENkkbg==",
|
"integrity": "sha512-oXrEjOuP3+J9pPNw3cmOnRma/xiVQ4WIIvGd6YkhPQgqqi2PnD/b1qfNY0AMead3QfNhKwKdDM4QFJdN2LpByg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "7.0.6"
|
"cross-spawn": "7.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/debug": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
@@ -399,13 +495,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.7.0",
|
"version": "25.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
|
||||||
"integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==",
|
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.21.0"
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/unist": {
|
"node_modules/@types/unist": {
|
||||||
@@ -790,6 +886,17 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/devlop": {
|
"node_modules/devlop": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||||
@@ -804,6 +911,35 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/effect": {
|
||||||
|
"version": "4.0.0-beta.74",
|
||||||
|
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.74.tgz",
|
||||||
|
"integrity": "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"fast-check": "^4.8.0",
|
||||||
|
"find-my-way-ts": "^0.1.6",
|
||||||
|
"ini": "^7.0.0",
|
||||||
|
"kubernetes-types": "^1.30.0",
|
||||||
|
"msgpackr": "^2.0.1",
|
||||||
|
"multipasta": "^0.2.7",
|
||||||
|
"toml": "^4.1.1",
|
||||||
|
"uuid": "^14.0.0",
|
||||||
|
"yaml": "^2.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/effect/node_modules/ini": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
@@ -1024,6 +1160,29 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-check": {
|
||||||
|
"version": "4.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz",
|
||||||
|
"integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pure-rand": "^8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -1091,6 +1250,13 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/find-my-way-ts": {
|
||||||
|
"version": "0.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
||||||
|
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/find-up": {
|
"node_modules/find-up": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||||
@@ -1530,6 +1696,13 @@
|
|||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kubernetes-types": {
|
||||||
|
"version": "1.30.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
||||||
|
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -2300,6 +2473,46 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/msgpackr": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"msgpackr-extract": "^3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/msgpackr-extract": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"node-gyp-build-optional-packages": "5.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/multipasta": {
|
||||||
|
"version": "0.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
||||||
|
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/natural-compare": {
|
"node_modules/natural-compare": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||||
@@ -2307,6 +2520,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build-optional-packages": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build-optional-packages": "bin.js",
|
||||||
|
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||||
|
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -2469,6 +2698,23 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pure-rand": {
|
||||||
|
"version": "8.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
|
||||||
|
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -2712,6 +2958,16 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/toml": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
@@ -2746,9 +3002,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.21.0",
|
"version": "7.24.6",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||||
"integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==",
|
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -2762,6 +3018,20 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "14.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
|
||||||
|
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist-node/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/v8-to-istanbul": {
|
"node_modules/v8-to-istanbul": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||||
@@ -2813,6 +3083,22 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
"install.sh",
|
"install.sh",
|
||||||
"manifests/",
|
"manifests/",
|
||||||
"mcp-configs/",
|
"mcp-configs/",
|
||||||
|
"plugins/ecc/",
|
||||||
"rules/",
|
"rules/",
|
||||||
"schemas/",
|
"schemas/",
|
||||||
"scripts/catalog.js",
|
"scripts/catalog.js",
|
||||||
|
|||||||
@@ -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."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/<name>`).
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
@@ -107,11 +107,11 @@ if [[ -f "$CONFIG_FILE" ]]; then
|
|||||||
check_config_pattern '^\[profiles\.strict\]' "profiles.strict exists"
|
check_config_pattern '^\[profiles\.strict\]' "profiles.strict exists"
|
||||||
check_config_pattern '^\[profiles\.yolo\]' "profiles.yolo exists"
|
check_config_pattern '^\[profiles\.yolo\]' "profiles.yolo exists"
|
||||||
|
|
||||||
|
# Current default connector set (docs/MCP-CONNECTOR-POLICY.md): exactly
|
||||||
|
# one connector. Former defaults (github, memory, sequential-thinking,
|
||||||
|
# context7, exa, ...) are opt-in user choices, so they are not required.
|
||||||
for section in \
|
for section in \
|
||||||
'mcp_servers.github' \
|
'mcp_servers.chrome-devtools'
|
||||||
'mcp_servers.memory' \
|
|
||||||
'mcp_servers.sequential-thinking' \
|
|
||||||
'mcp_servers.context7'
|
|
||||||
do
|
do
|
||||||
if search_file "^\[$section\]" "$CONFIG_FILE"; then
|
if search_file "^\[$section\]" "$CONFIG_FILE"; then
|
||||||
ok "MCP section [$section] exists"
|
ok "MCP section [$section] exists"
|
||||||
@@ -120,25 +120,17 @@ if [[ -f "$CONFIG_FILE" ]]; then
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
has_context7_legacy=0
|
# ECC <= 2.0.0 emitted a url-only exa entry that Codex's stdio-only
|
||||||
has_context7_current=0
|
# schema rejects, breaking the whole config (#2224). Flag it so users
|
||||||
|
# re-run the sync (which repairs it) or remove it manually.
|
||||||
if search_file '^\[mcp_servers\.context7\]' "$CONFIG_FILE"; then
|
if search_file '^\[mcp_servers\.exa\]' "$CONFIG_FILE"; then
|
||||||
has_context7_legacy=1
|
exa_block="$(awk '/^\[mcp_servers\.exa\]/{flag=1;next}/^\[/{flag=0}flag' "$CONFIG_FILE")"
|
||||||
fi
|
if printf '%s\n' "$exa_block" | grep -Eq '^[[:space:]]*url[[:space:]]*=' \
|
||||||
|
&& ! printf '%s\n' "$exa_block" | grep -Eq '^[[:space:]]*command[[:space:]]*='; then
|
||||||
if search_file '^\[mcp_servers\.context7-mcp\]' "$CONFIG_FILE"; then
|
fail "MCP section [mcp_servers.exa] uses a url key, which Codex rejects for stdio servers — re-run ecc-sync-codex to repair (#2224)"
|
||||||
has_context7_current=1
|
else
|
||||||
fi
|
ok "MCP section [mcp_servers.exa] uses the stdio form"
|
||||||
|
fi
|
||||||
if [[ "$has_context7_legacy" -eq 1 || "$has_context7_current" -eq 1 ]]; then
|
|
||||||
ok "MCP section [mcp_servers.context7] or [mcp_servers.context7-mcp] exists"
|
|
||||||
else
|
|
||||||
fail "MCP section [mcp_servers.context7] or [mcp_servers.context7-mcp] missing"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$has_context7_legacy" -eq 1 && "$has_context7_current" -eq 1 ]]; then
|
|
||||||
warn "Both [mcp_servers.context7] and [mcp_servers.context7-mcp] exist; prefer one name"
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -65,14 +65,14 @@ const PM_EXEC_PARTS = PM_EXEC.split(/\s+/); // ["pnpm", "dlx"] or ["npx"] or ["b
|
|||||||
// ECC-recommended MCP servers
|
// ECC-recommended MCP servers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// GitHub bootstrap uses bash for token forwarding — this is intentionally
|
|
||||||
// shell-based regardless of package manager, since Codex runs on macOS/Linux.
|
|
||||||
const GH_BOOTSTRAP = `token=$(gh auth token 2>/dev/null || true); if [ -n "$token" ]; then export GITHUB_PERSONAL_ACCESS_TOKEN="$token"; fi; exec ${PM_EXEC} @modelcontextprotocol/server-github`;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a server spec with the detected package manager.
|
* Build a server spec with the detected package manager.
|
||||||
* Returns { fields, toml } where fields is for drift detection and
|
* Returns { fields, toml } where fields is for drift detection and
|
||||||
* toml is the raw text appended to the file.
|
* toml is the raw text appended to the file.
|
||||||
|
*
|
||||||
|
* Codex's [mcp_servers.*] TOML schema is stdio-only (command/args) —
|
||||||
|
* never emit a `url` key here. The http/url form is valid only for
|
||||||
|
* Claude Code's .mcp.json (#2224).
|
||||||
*/
|
*/
|
||||||
function dlxServer(name, pkg, extraFields, extraToml) {
|
function dlxServer(name, pkg, extraFields, extraToml) {
|
||||||
const args = [...PM_EXEC_PARTS.slice(1), pkg];
|
const args = [...PM_EXEC_PARTS.slice(1), pkg];
|
||||||
@@ -87,31 +87,29 @@ function dlxServer(name, pkg, extraFields, extraToml) {
|
|||||||
const DEFAULT_MCP_STARTUP_TIMEOUT_SEC = 30;
|
const DEFAULT_MCP_STARTUP_TIMEOUT_SEC = 30;
|
||||||
const DEFAULT_MCP_STARTUP_TIMEOUT_TOML = `startup_timeout_sec = ${DEFAULT_MCP_STARTUP_TIMEOUT_SEC}`;
|
const DEFAULT_MCP_STARTUP_TIMEOUT_TOML = `startup_timeout_sec = ${DEFAULT_MCP_STARTUP_TIMEOUT_SEC}`;
|
||||||
|
|
||||||
|
// Current default connector set (docs/MCP-CONNECTOR-POLICY.md): exactly one
|
||||||
|
// connector. The former defaults (supabase, playwright, context7, exa,
|
||||||
|
// github, memory, sequential-thinking) were retired in the June 2026 audit
|
||||||
|
// and must not be re-emitted; they remain opt-in via
|
||||||
|
// mcp-configs/mcp-servers.json. Existing user-managed entries are never
|
||||||
|
// touched by the merge (add-only), except the known-invalid repair below.
|
||||||
const ECC_SERVERS = {
|
const ECC_SERVERS = {
|
||||||
supabase: dlxServer('supabase', '@supabase/mcp-server-supabase@latest', { startup_timeout_sec: 20.0, tool_timeout_sec: 120.0 }, 'startup_timeout_sec = 20.0\ntool_timeout_sec = 120.0'),
|
'chrome-devtools': dlxServer('chrome-devtools', 'chrome-devtools-mcp@latest', { startup_timeout_sec: DEFAULT_MCP_STARTUP_TIMEOUT_SEC }, DEFAULT_MCP_STARTUP_TIMEOUT_TOML)
|
||||||
playwright: dlxServer('playwright', '@playwright/mcp@latest', { startup_timeout_sec: DEFAULT_MCP_STARTUP_TIMEOUT_SEC }, DEFAULT_MCP_STARTUP_TIMEOUT_TOML),
|
|
||||||
context7: dlxServer('context7', '@upstash/context7-mcp@latest', { startup_timeout_sec: DEFAULT_MCP_STARTUP_TIMEOUT_SEC }, DEFAULT_MCP_STARTUP_TIMEOUT_TOML),
|
|
||||||
exa: {
|
|
||||||
fields: { url: 'https://mcp.exa.ai/mcp' },
|
|
||||||
toml: `[mcp_servers.exa]\nurl = "https://mcp.exa.ai/mcp"`
|
|
||||||
},
|
|
||||||
github: {
|
|
||||||
fields: { command: 'bash', args: ['-lc', GH_BOOTSTRAP], startup_timeout_sec: DEFAULT_MCP_STARTUP_TIMEOUT_SEC },
|
|
||||||
toml: `[mcp_servers.github]\ncommand = "bash"\nargs = ["-lc", ${JSON.stringify(GH_BOOTSTRAP)}]\n${DEFAULT_MCP_STARTUP_TIMEOUT_TOML}`
|
|
||||||
},
|
|
||||||
memory: dlxServer('memory', '@modelcontextprotocol/server-memory', { startup_timeout_sec: DEFAULT_MCP_STARTUP_TIMEOUT_SEC }, DEFAULT_MCP_STARTUP_TIMEOUT_TOML),
|
|
||||||
'sequential-thinking': dlxServer('sequential-thinking', '@modelcontextprotocol/server-sequential-thinking', { startup_timeout_sec: DEFAULT_MCP_STARTUP_TIMEOUT_SEC }, DEFAULT_MCP_STARTUP_TIMEOUT_TOML)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Append --features arg for supabase after dlxServer builds the base
|
// ECC <= 2.0.0 emitted [mcp_servers.exa] with a `url` key. Codex rejects
|
||||||
ECC_SERVERS.supabase.fields.args.push('--features=account,docs,database,debugging,development,functions,storage,branching');
|
// `url` for stdio servers, which makes the *entire* config.toml fail to
|
||||||
ECC_SERVERS.supabase.toml = ECC_SERVERS.supabase.toml.replace(/^(args = \[.*)\]$/m, '$1, "--features=account,docs,database,debugging,development,functions,storage,branching"]');
|
// load (#2224). Repair exactly that ECC-emitted form on every merge so
|
||||||
|
// re-running the installer fixes broken configs instead of preserving
|
||||||
|
// them. A user-managed stdio exa entry (command/args) is left untouched.
|
||||||
|
const RETIRED_INVALID_URL_SERVERS = {
|
||||||
|
exa: 'https://mcp.exa.ai/mcp'
|
||||||
|
};
|
||||||
|
|
||||||
// Legacy section names that should be treated as an existing ECC server.
|
// Legacy section names that should be treated as an existing ECC server.
|
||||||
// e.g. older configs shipped [mcp_servers.context7-mcp] instead of [mcp_servers.context7].
|
// e.g. older configs shipped [mcp_servers.context7-mcp] instead of
|
||||||
const LEGACY_ALIASES = {
|
// [mcp_servers.context7]. Empty since the June 2026 default-set reduction.
|
||||||
context7: ['context7-mcp']
|
const LEGACY_ALIASES = {};
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -241,6 +239,21 @@ function main() {
|
|||||||
const toAppend = [];
|
const toAppend = [];
|
||||||
const toRemoveLog = [];
|
const toRemoveLog = [];
|
||||||
|
|
||||||
|
// Repair schema-invalid entries emitted by earlier ECC versions (#2224).
|
||||||
|
for (const [name, invalidUrl] of Object.entries(RETIRED_INVALID_URL_SERVERS)) {
|
||||||
|
const entry = existing[name];
|
||||||
|
const isBrokenEccForm =
|
||||||
|
entry &&
|
||||||
|
typeof entry.url === 'string' &&
|
||||||
|
entry.url === invalidUrl &&
|
||||||
|
typeof entry.command !== 'string';
|
||||||
|
if (isBrokenEccForm) {
|
||||||
|
toRemoveLog.push(`mcp_servers.${name} (invalid url entry from earlier ECC versions)`);
|
||||||
|
raw = removeServerFromText(raw, name, existing);
|
||||||
|
log(` [repair] mcp_servers.${name} — url is not valid for Codex stdio servers, removing`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const [name, spec] of Object.entries(ECC_SERVERS)) {
|
for (const [name, spec] of Object.entries(ECC_SERVERS)) {
|
||||||
const entry = existing[name];
|
const entry = existing[name];
|
||||||
const aliases = LEGACY_ALIASES[name] || [];
|
const aliases = LEGACY_ALIASES[name] || [];
|
||||||
@@ -249,7 +262,9 @@ function main() {
|
|||||||
// Prefer canonical entry over legacy alias
|
// Prefer canonical entry over legacy alias
|
||||||
const hasCanonical = entry && typeof entry.command === 'string';
|
const hasCanonical = entry && typeof entry.command === 'string';
|
||||||
const resolvedEntry = hasCanonical ? entry : legacyName ? existing[legacyName] : null;
|
const resolvedEntry = hasCanonical ? entry : legacyName ? existing[legacyName] : null;
|
||||||
// For URL-based servers (exa), check for url field instead of command
|
// Recognize url-form entries as existing so they are never duplicated.
|
||||||
|
// (Codex itself rejects url-form stdio servers; ECC only ever emits
|
||||||
|
// command/args, but a user-managed entry must still count as present.)
|
||||||
const urlEntry = !resolvedEntry && entry && typeof entry.url === 'string' ? entry : null;
|
const urlEntry = !resolvedEntry && entry && typeof entry.url === 'string' ? entry : null;
|
||||||
const finalEntry = resolvedEntry || urlEntry;
|
const finalEntry = resolvedEntry || urlEntry;
|
||||||
const resolvedLabel = hasCanonical ? name : legacyName || name;
|
const resolvedLabel = hasCanonical ? name : legacyName || name;
|
||||||
@@ -306,11 +321,13 @@ function main() {
|
|||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
if (toRemoveLog.length > 0) {
|
if (toRemoveLog.length > 0) {
|
||||||
log('Dry run — would remove and re-add:');
|
log('Dry run — would remove:');
|
||||||
for (const label of toRemoveLog) log(` [remove] ${label}`);
|
for (const label of toRemoveLog) log(` [remove] ${label}`);
|
||||||
}
|
}
|
||||||
log('Dry run — would append:');
|
if (toAppend.length > 0) {
|
||||||
console.log(appendText);
|
log('Dry run — would append:');
|
||||||
|
console.log(appendText);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +342,7 @@ function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasRemovals && toAppend.length === 0) {
|
if (hasRemovals && toAppend.length === 0) {
|
||||||
log(`Done. Removed ${toRemoveLog.length} disabled server(s).`);
|
log(`Done. Removed ${toRemoveLog.length} server section(s).`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,20 +28,40 @@ const EXCLUDED_PATTERNS = [
|
|||||||
|
|
||||||
const MAX_STDIN = 1024 * 1024; // 1MB limit
|
const MAX_STDIN = 1024 * 1024; // 1MB limit
|
||||||
let data = '';
|
let data = '';
|
||||||
|
let truncated = false;
|
||||||
process.stdin.setEncoding('utf8');
|
process.stdin.setEncoding('utf8');
|
||||||
|
|
||||||
process.stdin.on('data', chunk => {
|
process.stdin.on('data', chunk => {
|
||||||
if (data.length < MAX_STDIN) {
|
if (data.length < MAX_STDIN) {
|
||||||
const remaining = MAX_STDIN - data.length;
|
const remaining = MAX_STDIN - data.length;
|
||||||
data += chunk.substring(0, remaining);
|
data += chunk.substring(0, remaining);
|
||||||
|
if (chunk.length > remaining) truncated = true;
|
||||||
|
} else {
|
||||||
|
truncated = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Echo stdin back (ECC pass-through convention), then exit once the pipe has
|
||||||
|
* flushed. Truncated stdin is never echoed: a JSON document cut mid-stream is
|
||||||
|
* reported by the harness as a Stop hook JSON validation failure (#2090).
|
||||||
|
*/
|
||||||
|
function passThroughAndExit() {
|
||||||
|
if (truncated) {
|
||||||
|
log('[Hook] check-console-log: stdin exceeded 1MB; suppressing pass-through (fail-open)');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.stdout.write(data, () => process.exit(0));
|
||||||
|
}
|
||||||
|
|
||||||
process.stdin.on('end', () => {
|
process.stdin.on('end', () => {
|
||||||
try {
|
try {
|
||||||
if (!isGitRepo()) {
|
if (!isGitRepo()) {
|
||||||
process.stdout.write(data);
|
passThroughAndExit();
|
||||||
process.exit(0);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = getGitModifiedFiles(['\\.tsx?$', '\\.jsx?$'])
|
const files = getGitModifiedFiles(['\\.tsx?$', '\\.jsx?$'])
|
||||||
@@ -65,7 +85,6 @@ process.stdin.on('end', () => {
|
|||||||
log(`[Hook] check-console-log error: ${err.message}`);
|
log(`[Hook] check-console-log error: ${err.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always output the original data
|
// Always output the original data (unless truncated)
|
||||||
process.stdout.write(data);
|
passThroughAndExit();
|
||||||
process.exit(0);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -128,12 +128,22 @@ function sumUsageFromTranscript(transcriptPath) {
|
|||||||
return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model };
|
return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model };
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_STDIN = 64 * 1024;
|
// 1MB, matching the other Stop hooks. The Stop payload carries
|
||||||
|
// last_assistant_message, which routinely exceeded the old 64KB cap and
|
||||||
|
// made this hook echo a JSON document cut mid-stream (#2090).
|
||||||
|
const MAX_STDIN = 1024 * 1024;
|
||||||
let raw = '';
|
let raw = '';
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
process.stdin.setEncoding('utf8');
|
process.stdin.setEncoding('utf8');
|
||||||
process.stdin.on('data', chunk => {
|
process.stdin.on('data', chunk => {
|
||||||
if (raw.length < MAX_STDIN) raw += chunk.substring(0, MAX_STDIN - raw.length);
|
if (raw.length < MAX_STDIN) {
|
||||||
|
const remaining = MAX_STDIN - raw.length;
|
||||||
|
raw += chunk.substring(0, remaining);
|
||||||
|
if (chunk.length > remaining) truncated = true;
|
||||||
|
} else {
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
process.stdin.on('end', () => {
|
process.stdin.on('end', () => {
|
||||||
@@ -201,6 +211,11 @@ process.stdin.on('end', () => {
|
|||||||
// Non-blocking — never fail the Stop hook.
|
// Non-blocking — never fail the Stop hook.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass stdin through (required by ECC hook convention).
|
// Pass stdin through (ECC hook convention) — but never echo truncated
|
||||||
|
// stdin: invalid JSON on stdout is reported as a Stop hook failure (#2090).
|
||||||
|
if (truncated) {
|
||||||
|
process.stderr.write('[Hook] cost-tracker: stdin exceeded 1MB; suppressing pass-through (fail-open)\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
process.stdout.write(raw);
|
process.stdout.write(raw);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -236,15 +236,26 @@ module.exports = { run };
|
|||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
const MAX_STDIN = 1024 * 1024;
|
const MAX_STDIN = 1024 * 1024;
|
||||||
let data = '';
|
let data = '';
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
process.stdin.setEncoding('utf8');
|
process.stdin.setEncoding('utf8');
|
||||||
process.stdin.on('data', chunk => {
|
process.stdin.on('data', chunk => {
|
||||||
if (data.length < MAX_STDIN) {
|
if (data.length < MAX_STDIN) {
|
||||||
data += chunk.substring(0, MAX_STDIN - data.length);
|
const remaining = MAX_STDIN - data.length;
|
||||||
|
data += chunk.substring(0, remaining);
|
||||||
|
if (chunk.length > remaining) truncated = true;
|
||||||
|
} else {
|
||||||
|
truncated = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
process.stdin.on('end', () => {
|
process.stdin.on('end', () => {
|
||||||
const output = run(data);
|
const output = run(data);
|
||||||
|
// Never echo truncated stdin — invalid JSON on stdout is reported as a
|
||||||
|
// Stop hook failure (#2090).
|
||||||
|
if (truncated) {
|
||||||
|
log('[DesktopNotify] stdin exceeded 1MB; suppressing pass-through (fail-open)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (output) process.stdout.write(output);
|
if (output) process.stdout.write(output);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -592,6 +592,7 @@ function saveState(state) {
|
|||||||
|
|
||||||
let mergedChecked = Array.isArray(state.checked) ? state.checked : [];
|
let mergedChecked = Array.isArray(state.checked) ? state.checked : [];
|
||||||
let mergedLastActive = typeof state.last_active === 'number' ? state.last_active : 0;
|
let mergedLastActive = typeof state.last_active === 'number' ? state.last_active : 0;
|
||||||
|
let mergedDenials = getDenialCount(state);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(stateFile)) {
|
if (fs.existsSync(stateFile)) {
|
||||||
@@ -602,6 +603,7 @@ function saveState(state) {
|
|||||||
if (typeof diskState.last_active === 'number') {
|
if (typeof diskState.last_active === 'number') {
|
||||||
mergedLastActive = Math.max(mergedLastActive, diskState.last_active);
|
mergedLastActive = Math.max(mergedLastActive, diskState.last_active);
|
||||||
}
|
}
|
||||||
|
mergedDenials = Math.max(mergedDenials, getDenialCount(diskState));
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
/* ignore malformed or transient disk state */
|
/* ignore malformed or transient disk state */
|
||||||
@@ -609,7 +611,8 @@ function saveState(state) {
|
|||||||
|
|
||||||
const finalState = {
|
const finalState = {
|
||||||
checked: pruneCheckedEntries(mergedChecked),
|
checked: pruneCheckedEntries(mergedChecked),
|
||||||
last_active: Math.max(mergedLastActive, Date.now())
|
last_active: Math.max(mergedLastActive, Date.now()),
|
||||||
|
fact_force_denials: mergedDenials
|
||||||
};
|
};
|
||||||
|
|
||||||
// Atomic write: temp file + rename prevents partial reads
|
// Atomic write: temp file + rename prevents partial reads
|
||||||
@@ -652,6 +655,48 @@ function markChecked(key) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Fact-force denial dampening (#2142) ---
|
||||||
|
//
|
||||||
|
// In long sessions the near-identical four-fact deny blocks accumulate in
|
||||||
|
// the context window and measurably raise the odds of the model dropping
|
||||||
|
// into a degenerate repetition loop. Emit the full four-fact block only for
|
||||||
|
// the first GATEGUARD_FACT_FORCE_FULL_DENIALS denials per session (default
|
||||||
|
// 3); afterwards emit a condensed single-line denial that carries the
|
||||||
|
// denial ordinal, so consecutive denials are structurally different and
|
||||||
|
// never textually identical. True retries of an already-gated target are
|
||||||
|
// unaffected (they were always allowed). Destructive-Bash and routine-Bash
|
||||||
|
// gates are unchanged.
|
||||||
|
|
||||||
|
const DEFAULT_FULL_DENIALS = 3;
|
||||||
|
|
||||||
|
function getFullDenialBudget() {
|
||||||
|
const raw = Number.parseInt(process.env.GATEGUARD_FACT_FORCE_FULL_DENIALS || '', 10);
|
||||||
|
if (Number.isInteger(raw) && raw >= 0) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return DEFAULT_FULL_DENIALS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDenialCount(state) {
|
||||||
|
const n = Number(state && state.fact_force_denials);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a first-touch target AND count the fact-force denial in the same
|
||||||
|
* state write. Returns the new denial ordinal (1-based) plus whether the
|
||||||
|
* write persisted.
|
||||||
|
*/
|
||||||
|
function markCheckedAndCountDenial(key) {
|
||||||
|
const state = loadState();
|
||||||
|
if (!state.checked.includes(key)) {
|
||||||
|
state.checked.push(key);
|
||||||
|
}
|
||||||
|
const denials = getDenialCount(state) + 1;
|
||||||
|
state.fact_force_denials = denials;
|
||||||
|
return { ok: saveState(state), denials };
|
||||||
|
}
|
||||||
|
|
||||||
function isChecked(key) {
|
function isChecked(key) {
|
||||||
const state = loadState();
|
const state = loadState();
|
||||||
const found = state.checked.includes(key);
|
const found = state.checked.includes(key);
|
||||||
@@ -792,6 +837,20 @@ function writeGateMsg(filePath) {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Condensed single-line denial used after the full-block budget is spent
|
||||||
|
* (#2142). Carries the denial ordinal so consecutive denials differ
|
||||||
|
* textually, and a one-line recovery hint instead of the multi-line block.
|
||||||
|
*/
|
||||||
|
function condensedGateMsg(action, filePath, ordinal) {
|
||||||
|
const safe = sanitizePath(filePath);
|
||||||
|
return (
|
||||||
|
`[Fact-Forcing Gate] (denial #${ordinal} this session) First ${action} of ${safe}: ` +
|
||||||
|
"briefly state importers/callers, affected API, data schemas if any, and the user's verbatim instruction, then retry. " +
|
||||||
|
'(ECC_GATEGUARD=off disables this gate.)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function destructiveBashMsg() {
|
function destructiveBashMsg() {
|
||||||
return [
|
return [
|
||||||
'[Fact-Forcing Gate]',
|
'[Fact-Forcing Gate]',
|
||||||
@@ -902,9 +961,14 @@ function run(rawInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isChecked(filePath)) {
|
if (!isChecked(filePath)) {
|
||||||
if (!markChecked(filePath)) {
|
const { ok, denials } = markCheckedAndCountDenial(filePath);
|
||||||
|
if (!ok) {
|
||||||
return allowWithStateWarning();
|
return allowWithStateWarning();
|
||||||
}
|
}
|
||||||
|
if (denials > getFullDenialBudget()) {
|
||||||
|
const action = toolName === 'Edit' ? 'edit' : 'creation';
|
||||||
|
return denyResult(condensedGateMsg(action, filePath, denials), { includeRecoveryHint: false });
|
||||||
|
}
|
||||||
return denyResult(toolName === 'Edit' ? editGateMsg(filePath) : writeGateMsg(filePath));
|
return denyResult(toolName === 'Edit' ? editGateMsg(filePath) : writeGateMsg(filePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,9 +984,13 @@ function run(rawInput) {
|
|||||||
for (const edit of edits) {
|
for (const edit of edits) {
|
||||||
const filePath = edit.file_path || '';
|
const filePath = edit.file_path || '';
|
||||||
if (filePath && !isClaudeSettingsPath(filePath) && !isChecked(filePath)) {
|
if (filePath && !isClaudeSettingsPath(filePath) && !isChecked(filePath)) {
|
||||||
if (!markChecked(filePath)) {
|
const { ok, denials } = markCheckedAndCountDenial(filePath);
|
||||||
|
if (!ok) {
|
||||||
return allowWithStateWarning();
|
return allowWithStateWarning();
|
||||||
}
|
}
|
||||||
|
if (denials > getFullDenialBudget()) {
|
||||||
|
return denyResult(condensedGateMsg('edit', filePath, denials), { includeRecoveryHint: false });
|
||||||
|
}
|
||||||
return denyResult(editGateMsg(filePath));
|
return denyResult(editGateMsg(filePath));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,40 +45,52 @@ function writeStderr(stderr) {
|
|||||||
process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`);
|
process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitHookResult(raw, output) {
|
/**
|
||||||
|
* Write stdout fully, then exit. `process.exit()` immediately after
|
||||||
|
* `process.stdout.write()` drops anything beyond the ~64KB pipe buffer,
|
||||||
|
* which cut large pass-through payloads mid-JSON and made the harness
|
||||||
|
* treat the hook as failed (#2222). The write callback fires only after
|
||||||
|
* the chunk is flushed to the pipe.
|
||||||
|
*/
|
||||||
|
function exitWithStdout(text, exitCode) {
|
||||||
|
if (typeof text !== 'string' || text.length === 0) {
|
||||||
|
process.exit(exitCode);
|
||||||
|
}
|
||||||
|
process.stdout.write(text, () => process.exit(exitCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHookResult(raw, output) {
|
||||||
if (typeof output === 'string' || Buffer.isBuffer(output)) {
|
if (typeof output === 'string' || Buffer.isBuffer(output)) {
|
||||||
process.stdout.write(String(output));
|
return { stdout: String(output), exitCode: 0 };
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (output && typeof output === 'object') {
|
if (output && typeof output === 'object') {
|
||||||
writeStderr(output.stderr);
|
writeStderr(output.stderr);
|
||||||
|
const exitCode = Number.isInteger(output.exitCode) ? output.exitCode : 0;
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(output, 'additionalContext')) {
|
if (Object.prototype.hasOwnProperty.call(output, 'additionalContext')) {
|
||||||
process.stdout.write(buildPreToolUseAdditionalContext(output.additionalContext));
|
return { stdout: buildPreToolUseAdditionalContext(output.additionalContext), exitCode };
|
||||||
} else if (Object.prototype.hasOwnProperty.call(output, 'stdout')) {
|
|
||||||
process.stdout.write(String(output.stdout ?? ''));
|
|
||||||
} else if (!Number.isInteger(output.exitCode) || output.exitCode === 0) {
|
|
||||||
process.stdout.write(raw);
|
|
||||||
}
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(output, 'stdout')) {
|
||||||
return Number.isInteger(output.exitCode) ? output.exitCode : 0;
|
return { stdout: String(output.stdout ?? ''), exitCode };
|
||||||
|
}
|
||||||
|
return { stdout: exitCode === 0 ? raw : '', exitCode };
|
||||||
}
|
}
|
||||||
|
|
||||||
process.stdout.write(raw);
|
return { stdout: raw, exitCode: 0 };
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeLegacySpawnOutput(raw, result) {
|
function resolveLegacySpawnStdout(raw, result) {
|
||||||
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
||||||
if (stdout) {
|
if (stdout) {
|
||||||
process.stdout.write(stdout);
|
return stdout;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Number.isInteger(result.status) && result.status === 0) {
|
if (Number.isInteger(result.status) && result.status === 0) {
|
||||||
process.stdout.write(raw);
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPluginRoot() {
|
function getPluginRoot() {
|
||||||
@@ -92,14 +104,25 @@ async function main() {
|
|||||||
const [, , hookId, relScriptPath, profilesCsv] = process.argv;
|
const [, , hookId, relScriptPath, profilesCsv] = process.argv;
|
||||||
const { raw, truncated } = await readStdinRaw();
|
const { raw, truncated } = await readStdinRaw();
|
||||||
|
|
||||||
|
// Oversized payloads: never echo the truncated string — a JSON document
|
||||||
|
// cut mid-stream is treated by the harness as a hook failure, blocking the
|
||||||
|
// tool call (#2222). Empty stdout + exit 0 means "no opinion", so
|
||||||
|
// pass-through paths fail open. The hook itself still runs and receives
|
||||||
|
// the truncated flag (run() context / ECC_HOOK_INPUT_TRUNCATED), so
|
||||||
|
// security hooks like config-protection can still choose to block.
|
||||||
|
const sanitizeEcho = text => (truncated && text === raw ? '' : text);
|
||||||
|
if (truncated) {
|
||||||
|
process.stderr.write(`[Hook] stdin exceeded ${MAX_STDIN} bytes for ${hookId || 'unknown'}; suppressing pass-through (fail-open unless the hook blocks)\n`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!hookId || !relScriptPath) {
|
if (!hookId || !relScriptPath) {
|
||||||
process.stdout.write(raw);
|
exitWithStdout(sanitizeEcho(raw), 0);
|
||||||
process.exit(0);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isHookEnabled(hookId, { profiles: profilesCsv })) {
|
if (!isHookEnabled(hookId, { profiles: profilesCsv })) {
|
||||||
process.stdout.write(raw);
|
exitWithStdout(sanitizeEcho(raw), 0);
|
||||||
process.exit(0);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginRoot = getPluginRoot();
|
const pluginRoot = getPluginRoot();
|
||||||
@@ -109,14 +132,14 @@ async function main() {
|
|||||||
// Prevent path traversal outside the plugin root
|
// Prevent path traversal outside the plugin root
|
||||||
if (!scriptPath.startsWith(resolvedRoot + path.sep)) {
|
if (!scriptPath.startsWith(resolvedRoot + path.sep)) {
|
||||||
process.stderr.write(`[Hook] Path traversal rejected for ${hookId}: ${scriptPath}\n`);
|
process.stderr.write(`[Hook] Path traversal rejected for ${hookId}: ${scriptPath}\n`);
|
||||||
process.stdout.write(raw);
|
exitWithStdout(sanitizeEcho(raw), 0);
|
||||||
process.exit(0);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(scriptPath)) {
|
if (!fs.existsSync(scriptPath)) {
|
||||||
process.stderr.write(`[Hook] Script not found for ${hookId}: ${scriptPath}\n`);
|
process.stderr.write(`[Hook] Script not found for ${hookId}: ${scriptPath}\n`);
|
||||||
process.stdout.write(raw);
|
exitWithStdout(sanitizeEcho(raw), 0);
|
||||||
process.exit(0);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer direct require() when the hook exports a run(rawInput) function.
|
// Prefer direct require() when the hook exports a run(rawInput) function.
|
||||||
@@ -147,12 +170,13 @@ async function main() {
|
|||||||
truncated,
|
truncated,
|
||||||
maxStdin: MAX_STDIN
|
maxStdin: MAX_STDIN
|
||||||
});
|
});
|
||||||
process.exit(emitHookResult(raw, output));
|
const result = resolveHookResult(raw, output);
|
||||||
|
exitWithStdout(sanitizeEcho(result.stdout), result.exitCode);
|
||||||
} catch (runErr) {
|
} catch (runErr) {
|
||||||
process.stderr.write(`[Hook] run() error for ${hookId}: ${runErr.message}\n`);
|
process.stderr.write(`[Hook] run() error for ${hookId}: ${runErr.message}\n`);
|
||||||
process.stdout.write(raw);
|
exitWithStdout(sanitizeEcho(raw), 0);
|
||||||
}
|
}
|
||||||
process.exit(0);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy path: spawn a child Node process for hooks without run() export
|
// Legacy path: spawn a child Node process for hooks without run() export
|
||||||
@@ -171,20 +195,17 @@ async function main() {
|
|||||||
timeout: 30000
|
timeout: 30000
|
||||||
});
|
});
|
||||||
|
|
||||||
writeLegacySpawnOutput(raw, result);
|
const legacyStdout = sanitizeEcho(resolveLegacySpawnStdout(raw, result));
|
||||||
if (result.stderr) process.stderr.write(result.stderr);
|
if (result.stderr) process.stderr.write(result.stderr);
|
||||||
|
|
||||||
if (result.error || result.signal || result.status === null) {
|
if (result.error || result.signal || result.status === null) {
|
||||||
const failureDetail = result.error
|
const failureDetail = result.error ? result.error.message : result.signal ? `terminated by signal ${result.signal}` : 'missing exit status';
|
||||||
? result.error.message
|
|
||||||
: result.signal
|
|
||||||
? `terminated by signal ${result.signal}`
|
|
||||||
: 'missing exit status';
|
|
||||||
writeStderr(`[Hook] legacy hook execution failed for ${hookId}: ${failureDetail}`);
|
writeStderr(`[Hook] legacy hook execution failed for ${hookId}: ${failureDetail}`);
|
||||||
process.exit(1);
|
exitWithStdout(legacyStdout, 1);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(Number.isInteger(result.status) ? result.status : 0);
|
exitWithStdout(legacyStdout, Number.isInteger(result.status) ? result.status : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(err => {
|
main().catch(err => {
|
||||||
|
|||||||
@@ -196,13 +196,30 @@ function run(rawInput) {
|
|||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
let stdinData = '';
|
let stdinData = '';
|
||||||
|
let truncated = false;
|
||||||
process.stdin.setEncoding('utf8');
|
process.stdin.setEncoding('utf8');
|
||||||
process.stdin.on('data', chunk => {
|
process.stdin.on('data', chunk => {
|
||||||
if (stdinData.length < MAX_STDIN) stdinData += chunk.substring(0, MAX_STDIN - stdinData.length);
|
if (stdinData.length < MAX_STDIN) {
|
||||||
|
const remaining = MAX_STDIN - stdinData.length;
|
||||||
|
stdinData += chunk.substring(0, remaining);
|
||||||
|
if (chunk.length > remaining) truncated = true;
|
||||||
|
} else {
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
process.stdin.on('end', () => {
|
process.stdin.on('end', () => {
|
||||||
process.stdout.write(run(stdinData));
|
const output = run(stdinData);
|
||||||
process.exit(0);
|
// Never echo truncated stdin (invalid JSON would be reported as a Stop
|
||||||
|
// hook failure, #2090); flush stdout before exiting so large payloads
|
||||||
|
// are not cut at the pipe buffer.
|
||||||
|
if (truncated) {
|
||||||
|
process.stderr.write('[Hook] stop-format-typecheck: stdin exceeded 1MB; suppressing pass-through (fail-open)\n');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (!output) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.stdout.write(output, () => process.exit(0));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,16 @@
|
|||||||
* - Strategic compacting preserves context through logical phases
|
* - Strategic compacting preserves context through logical phases
|
||||||
* - Compact after exploration, before execution
|
* - Compact after exploration, before execution
|
||||||
* - Compact after completing a milestone, before starting next
|
* - 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');
|
const fs = require('fs');
|
||||||
@@ -22,8 +32,18 @@ const {
|
|||||||
log,
|
log,
|
||||||
output
|
output
|
||||||
} = require('../lib/utils');
|
} = require('../lib/utils');
|
||||||
|
const {
|
||||||
|
readLatestContextTokens,
|
||||||
|
resolveContextWindowTokens,
|
||||||
|
resolveContextThreshold,
|
||||||
|
resolveContextInterval,
|
||||||
|
computeContextBucket,
|
||||||
|
formatWindowLabel
|
||||||
|
} = require('../lib/transcript-context');
|
||||||
|
|
||||||
const COUNTER_FILE_PREFIX = 'claude-tool-count-';
|
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;
|
const DEFAULT_COMPACT_STATE_TTL_DAYS = 14;
|
||||||
|
|
||||||
function getCounterRetentionDays() {
|
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-<sessionId>` into the OS temp
|
* Each session writes `claude-tool-count-<sessionId>` (and, with the context
|
||||||
* dir; nothing else removes them. Without a sweep these files accumulate
|
* signal, `claude-context-bucket-<sessionId>`) into the OS temp dir; nothing
|
||||||
* one-per-session forever. This helper removes counters whose mtime is
|
* else removes them. Without a sweep these files accumulate one-per-session
|
||||||
* older than `retentionDays`, while preserving the active session's
|
* forever. This helper removes state files whose mtime is older than
|
||||||
* counter (which is about to be re-written by the caller).
|
* `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
|
* The helper never throws; per the always-exit-0 hook contract any
|
||||||
* filesystem failure is swallowed and logged to stderr.
|
* filesystem failure is swallowed and logged to stderr.
|
||||||
*
|
*
|
||||||
* @param {string} tempDir - The temp directory to sweep.
|
* @param {string} tempDir - The temp directory to sweep.
|
||||||
* @param {number} retentionDays - Files older than this many days are removed.
|
* @param {number} retentionDays - Files older than this many days are removed.
|
||||||
* @param {string} currentCounterFile - Absolute path of the active session's
|
* @param {string[]} currentStateFiles - Absolute paths of the active session's
|
||||||
* counter file; preserved unconditionally.
|
* state files; preserved unconditionally.
|
||||||
*/
|
*/
|
||||||
function cleanupOldCounters(tempDir, retentionDays, currentCounterFile) {
|
function cleanupOldCounters(tempDir, retentionDays, currentStateFiles) {
|
||||||
let entries;
|
let entries;
|
||||||
try {
|
try {
|
||||||
entries = fs.readdirSync(tempDir, { withFileTypes: true });
|
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 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) {
|
for (const entry of entries) {
|
||||||
if (!entry.isFile()) continue;
|
if (!entry.isFile()) continue;
|
||||||
if (!entry.name.startsWith(COUNTER_FILE_PREFIX)) continue;
|
if (!STATE_FILE_PREFIXES.some(prefix => entry.name.startsWith(prefix))) continue;
|
||||||
if (entry.name === currentBasename) continue;
|
if (currentBasenames.has(entry.name)) continue;
|
||||||
|
|
||||||
const fullPath = path.join(tempDir, entry.name);
|
const fullPath = path.join(tempDir, entry.name);
|
||||||
let stats;
|
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
|
* Increment and persist the per-session tool-call counter.
|
||||||
// canonical field. Fall back to the legacy env var, then 'default'.
|
* Uses fd-based read+write to reduce (but not eliminate) the race window
|
||||||
try {
|
* between concurrent hook invocations.
|
||||||
const input = await readStdinJson({ timeoutMs: 1000 });
|
*/
|
||||||
if (input && typeof input.session_id === 'string' && input.session_id) {
|
function incrementToolCallCount(counterFile) {
|
||||||
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;
|
|
||||||
|
|
||||||
let count = 1;
|
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 {
|
try {
|
||||||
const fd = fs.openSync(counterFile, 'a+');
|
const fd = fs.openSync(counterFile, 'a+');
|
||||||
try {
|
try {
|
||||||
@@ -150,25 +142,124 @@ async function main() {
|
|||||||
writeFile(counterFile, String(count));
|
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,
|
// 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;
|
// 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
|
// it does not reach the model. To inject a user-facing suggestion without
|
||||||
// blocking the tool call, emit structured JSON to stdout with
|
// blocking the tool call, emit structured JSON to stdout with
|
||||||
// hookSpecificOutput.additionalContext — the documented mechanism for
|
// hookSpecificOutput.additionalContext — the documented mechanism for
|
||||||
// PreToolUse hooks to add context to the next model turn.
|
// PreToolUse hooks to add context to the next model turn. Hooks must emit
|
||||||
if (count === threshold) {
|
// at most one stdout JSON payload per run, so both signals share it.
|
||||||
const msg = `[StrategicCompact] ${threshold} tool calls reached - consider /compact if transitioning phases`;
|
if (messages.length > 0) {
|
||||||
log(msg);
|
for (const msg of messages) {
|
||||||
output({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: msg } });
|
log(msg);
|
||||||
}
|
}
|
||||||
|
output({
|
||||||
// Suggest at regular intervals after threshold (every 25 calls from threshold)
|
hookSpecificOutput: {
|
||||||
if (count > threshold && (count - threshold) % 25 === 0) {
|
hookEventName: 'PreToolUse',
|
||||||
const msg = `[StrategicCompact] ${count} tool calls - good checkpoint for /compact if context is stale`;
|
additionalContext: messages.join('\n')
|
||||||
log(msg);
|
}
|
||||||
output({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: msg } });
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -16,6 +16,7 @@ PLUGIN_JSON=".claude-plugin/plugin.json"
|
|||||||
MARKETPLACE_JSON=".claude-plugin/marketplace.json"
|
MARKETPLACE_JSON=".claude-plugin/marketplace.json"
|
||||||
CODEX_MARKETPLACE_JSON=".agents/plugins/marketplace.json"
|
CODEX_MARKETPLACE_JSON=".agents/plugins/marketplace.json"
|
||||||
CODEX_PLUGIN_JSON=".codex-plugin/plugin.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_JSON=".opencode/package.json"
|
||||||
OPENCODE_PACKAGE_LOCK_JSON=".opencode/package-lock.json"
|
OPENCODE_PACKAGE_LOCK_JSON=".opencode/package-lock.json"
|
||||||
OPENCODE_ECC_HOOKS_PLUGIN=".opencode/plugins/ecc-hooks.ts"
|
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_version "$MARKETPLACE_JSON" "0,/\"version\": *\"[^\"]*\"/s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|"
|
||||||
update_codex_marketplace_version
|
update_codex_marketplace_version
|
||||||
update_version "$CODEX_PLUGIN_JSON" "s|\"version\": *\"[^\"]*\"|\"version\": \"$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_version "$OPENCODE_PACKAGE_JSON" "s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|"
|
||||||
update_package_lock_version "$OPENCODE_PACKAGE_LOCK_JSON"
|
update_package_lock_version "$OPENCODE_PACKAGE_LOCK_JSON"
|
||||||
update_opencode_hook_banner_version
|
update_opencode_hook_banner_version
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
name: config-gc
|
||||||
|
description: Garbage collection for your Claude Code configuration. Periodically scans ~/.claude (skills, memory, hooks, permissions, MCP servers, caches) for redundant, stale, orphaned, or low-value items, then walks the user through a confirm-each-deletion cleanup. Use when the user says "clean up my config", "config GC", "too many skills", "audit my setup", "my .claude is bloated", or asks for a periodic config review.
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# Config GC — Garbage Collection for Claude Code Setups
|
||||||
|
|
||||||
|
Borrowed from runtime garbage collection: periodically scan for objects that are no longer referenced, redundant, expired, or low-value, and reclaim the space. The critical difference: **here, collection requires a human in the loop. Never delete autonomously.**
|
||||||
|
|
||||||
|
## When to Activate
|
||||||
|
|
||||||
|
- The user asks to clean up, audit, or slim down their Claude Code configuration
|
||||||
|
- The user complains about too many skills, noisy hooks, or slow session startup
|
||||||
|
- A monthly/periodic config review is due
|
||||||
|
- After installing a large skill pack (e.g. this repo), to reconcile overlaps with existing setup
|
||||||
|
|
||||||
|
Do NOT activate for: cleaning project source code (that's refactoring), clearing chat history, or uninstalling Claude Code itself.
|
||||||
|
|
||||||
|
## Design Philosophy
|
||||||
|
|
||||||
|
1. **Append-only configs leak.** Skills, memory files, hooks, and permission entries only ever get added. Without periodic review they rot silently.
|
||||||
|
2. **Regular audits beat one-time purges.** Scan every ~30 days, propose a small batch of candidates each time.
|
||||||
|
3. **Per-channel strategies.** Each accumulation type (skills, hooks, permissions, ...) has its own staleness signals — don't apply one rule everywhere.
|
||||||
|
4. **Soft-delete first.** Rename to `.disabled` > move to `~/.claude/_gc_trash/` > real deletion. Always keep an undo path.
|
||||||
|
5. **Forced human-in-the-loop.** Every candidate gets its own `[y/n/skip]` confirmation. No "yes to all" shortcut.
|
||||||
|
6. **Keep a log.** Every GC run appends to `~/.claude/gc_log.md`: what was touched, why, and how to undo it.
|
||||||
|
|
||||||
|
## Scan Channels
|
||||||
|
|
||||||
|
| # | Channel | Path | Staleness / redundancy signals |
|
||||||
|
|---|---------|------|--------------------------------|
|
||||||
|
| 1 | Skills | `~/.claude/skills/*/` | Heavily overlapping names; never triggered in recent transcripts; domain mismatch with the user's actual work; broken or empty SKILL.md |
|
||||||
|
| 2 | Memory | `~/.claude/**/memory/*.md` + its index | Multiple index entries for one topic; contents contradicting newer entries; dates that have passed; orphan files missing from the index; sub-100-word fragments that should merge |
|
||||||
|
| 3 | Hooks | `~/.claude/hooks/` + settings | Scripts present on disk but referenced by no hook config; old versions superseded by rewrites |
|
||||||
|
| 4 | Permissions | `permissions.allow` in `settings.json` / `settings.local.json` | Duplicate entries; specific entries already covered by a wildcard (e.g. `Bash(git push)` when `Bash(*)` is allowed); one-off grants from past experiments |
|
||||||
|
| 5 | MCP servers | `~/.claude.json` or project `.mcp.json` | Servers that fail to connect; functional duplicates; long-unused |
|
||||||
|
| 6 | Scheduled reminders / jobs | wherever the user keeps them | Fired one-shots older than 30 days; jobs whose target scripts no longer exist |
|
||||||
|
| 7 | Project history | `~/.claude/projects/*/` | Stale handoff snapshots; session records superseded by newer state |
|
||||||
|
| 8 | Runtime caches | `cache/`, `file-history/`, `logs/`, `shell-snapshots/` | Sort by size and mtime; propose items >30 days old and large |
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Scan** all channels (or the subset the user names). Collect candidates with: path, channel, signal that flagged it, size, last-modified.
|
||||||
|
2. **Rank** by confidence (broken/orphaned = high; merely old = low) and present as a numbered table. Cap each run at ~20 candidates — GC is periodic, not exhaustive.
|
||||||
|
3. **Confirm one by one.** For each candidate show the evidence, then ask `[y/n/skip]`. The user can stop at any point.
|
||||||
|
4. **Soft-delete confirmed items**: prefer `.disabled` rename for skills/hooks and `_gc_trash/<date>/` move for files. Permission entries live in JSON (no comments possible): back up the settings file, record each removed entry verbatim in `gc_log.md`, then remove it from the `allow` array with `jq`. Only hard-delete when the user explicitly asks.
|
||||||
|
5. **Log** the run to `~/.claude/gc_log.md`: timestamp, items actioned, undo instructions.
|
||||||
|
6. **Report**: reclaimed size, channels still healthy, suggested next review date.
|
||||||
|
|
||||||
|
## Example Scan Commands
|
||||||
|
|
||||||
|
Orphaned hook scripts (channel 3) — scripts on disk that no hook config references:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for f in ~/.claude/hooks/*; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
grep -rq "$name" ~/.claude/settings.json ~/.claude/settings.local.json 2>/dev/null \
|
||||||
|
|| echo "ORPHAN: $f"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Redundant permission entries (channel 4) — duplicates, and specific grants shadowed by a wildcard:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jq -r '.permissions.allow[]' ~/.claude/settings.local.json | sort | uniq -d
|
||||||
|
if jq -e '.permissions.allow | index("Bash(*)")' ~/.claude/settings.local.json >/dev/null; then
|
||||||
|
jq -r '.permissions.allow[]' ~/.claude/settings.local.json \
|
||||||
|
| grep '^Bash(' | grep -vF 'Bash(*)'
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
Largest stale caches (channel 8) — `du -k` instead of GNU-only `find -printf`, so it works on macOS/BSD too:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
find ~/.claude/file-history ~/.claude/shell-snapshots -type f -mtime +30 \
|
||||||
|
-exec du -k {} + 2>/dev/null | sort -rn | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Soft-delete with undo path (capture the date once so the log can't disagree with the directory):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gc_date=$(date +%Y-%m-%d)
|
||||||
|
mkdir -p ~/.claude/_gc_trash/$gc_date
|
||||||
|
mv ~/.claude/skills/dead-skill ~/.claude/_gc_trash/$gc_date/
|
||||||
|
echo "$(date -Iseconds) moved skills/dead-skill -> _gc_trash/$gc_date/ (undo: mv back)" >> ~/.claude/gc_log.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Removing a confirmed-redundant permission entry (JSON has no comments — back up, log, then edit):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp ~/.claude/settings.local.json ~/.claude/settings.local.json.bak
|
||||||
|
echo "$(date -Iseconds) removed permission entry: Bash(git push) (undo: restore from .bak or re-add)" >> ~/.claude/gc_log.md
|
||||||
|
jq '.permissions.allow -= ["Bash(git push)"]' ~/.claude/settings.local.json.bak \
|
||||||
|
> ~/.claude/settings.local.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
- **Bulk approval.** Asking "delete all 15? [y/n]" defeats the design. One item, one decision.
|
||||||
|
- **Hard-deleting on first pass.** If there's no `_gc_trash/` copy or `.disabled` rename, you did it wrong.
|
||||||
|
- **Treating "old" as "dead".** A skill untouched for 60 days may be seasonal (tax season, quarterly reviews). Age is a signal, not a verdict — that's why a human confirms.
|
||||||
|
- **Cleaning memory by truncation.** Merging two contradicting memory files requires reading both and keeping the newer truth, not deleting the longer one.
|
||||||
|
- **Touching anything outside `~/.claude`** (or the project's `.claude/`). Config GC never wanders into source trees.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Run after big additions, not just on a calendar: installing a 50-skill pack is exactly when overlap with existing skills appears.
|
||||||
|
- When two skills overlap, prefer disabling the one with the weaker trigger description — it's the one that was probably never firing anyway.
|
||||||
|
- Permission cleanup is the highest-value channel per minute spent: redundant allow-entries make security review harder.
|
||||||
|
- Keep `gc_log.md` forever. It's tiny, and "when did I disable that hook and why" comes up more often than you'd think.
|
||||||
|
|
||||||
|
## Related Skills
|
||||||
|
|
||||||
|
- `skill-stocktake` — audits skill *quality*; config-gc audits skill *existence*. Run stocktake on what survives GC.
|
||||||
|
- `workspace-surface-audit` — the additive counterpart: recommends what to install. config-gc is the subtractive half of the same lifecycle.
|
||||||
|
- `configure-ecc` — after installing skills with it, run config-gc to reconcile overlaps with your pre-existing setup.
|
||||||
|
- `continuous-learning` — produces the memory files this skill later audits.
|
||||||
|
- `security-review` — pairs well with the permissions channel.
|
||||||
@@ -98,6 +98,13 @@ If GateGuard blocks setup or repair work, start the session with
|
|||||||
`ECC_GATEGUARD=off`. For hook-level control, keep using
|
`ECC_GATEGUARD=off`. For hook-level control, keep using
|
||||||
`ECC_DISABLED_HOOKS` with the GateGuard hook ID.
|
`ECC_DISABLED_HOOKS` with the GateGuard hook ID.
|
||||||
|
|
||||||
|
In long sessions, only the first `GATEGUARD_FACT_FORCE_FULL_DENIALS`
|
||||||
|
fact-force denials (default 3) emit the full four-fact block; later
|
||||||
|
denials are condensed to a single line carrying the denial ordinal, so
|
||||||
|
near-identical blocks cannot accumulate in the context window and
|
||||||
|
amplify model repetition loops (#2142). Retrying the same file or
|
||||||
|
command after presenting facts never re-triggers the gate.
|
||||||
|
|
||||||
### Option B: Full package with config
|
### Option B: Full package with config
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -30,11 +30,12 @@ Strategic compaction at logical boundaries:
|
|||||||
|
|
||||||
## How It Works
|
## 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
|
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. **Threshold detection** — Suggests at configurable threshold (default: 50 calls)
|
2. **Tool-call count (secondary)** — Counts tool invocations in session; suggests at a configurable threshold (default: 50 calls), then every 25 calls after
|
||||||
3. **Periodic reminders** — Reminds every 25 calls after threshold
|
|
||||||
|
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
|
## Hook Setup
|
||||||
|
|
||||||
@@ -61,6 +62,9 @@ Add to your `~/.claude/settings.json`:
|
|||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
- `COMPACT_THRESHOLD` — Tool calls before first suggestion (default: 50)
|
- `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
|
## Compaction Decision Guide
|
||||||
|
|
||||||
|
|||||||
@@ -1637,6 +1637,114 @@ function runTests() {
|
|||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
// --- Fact-force denial dampening (#2142) ---
|
||||||
|
|
||||||
|
console.log('\n Fact-force denial dampening (#2142):');
|
||||||
|
|
||||||
|
clearState();
|
||||||
|
if (test('first denials use the full four-fact block and count toward the budget', () => {
|
||||||
|
const result = runHook({ tool_name: 'Edit', tool_input: { file_path: '/src/damp-one.js' } });
|
||||||
|
const output = parseOutput(result.stdout);
|
||||||
|
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||||
|
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('present these facts'),
|
||||||
|
'first denial should use the full block');
|
||||||
|
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||||
|
assert.strictEqual(state.fact_force_denials, 1, 'denial counter should persist in session state');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
clearState();
|
||||||
|
if (test('emits a condensed single-line denial once the full-block budget is spent', () => {
|
||||||
|
writeState({ checked: [], last_active: Date.now(), fact_force_denials: 3 });
|
||||||
|
const result = runHook({ tool_name: 'Edit', tool_input: { file_path: '/src/damp-two.js' } });
|
||||||
|
const output = parseOutput(result.stdout);
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny', 'still denies first touch');
|
||||||
|
const reason = output.hookSpecificOutput.permissionDecisionReason;
|
||||||
|
assert.ok(reason.includes('[Fact-Forcing Gate]'), 'condensed message keeps the gate marker');
|
||||||
|
assert.ok(reason.includes('denial #4'), 'condensed message carries the denial ordinal');
|
||||||
|
assert.ok(reason.includes('/src/damp-two.js'), 'condensed message names the target');
|
||||||
|
assert.ok(!reason.includes('present these facts'), 'no repeated four-fact block');
|
||||||
|
assert.ok(!reason.includes('\n'), 'condensed message is a single line');
|
||||||
|
assert.ok(reason.includes('ECC_GATEGUARD=off'), 'condensed message keeps a recovery hint');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
clearState();
|
||||||
|
if (test('consecutive condensed denials are textually different (ordinal advances)', () => {
|
||||||
|
writeState({ checked: [], last_active: Date.now(), fact_force_denials: 5 });
|
||||||
|
const first = parseOutput(runHook({ tool_name: 'Write', tool_input: { file_path: '/src/damp-a.js', content: 'x' } }).stdout);
|
||||||
|
const second = parseOutput(runHook({ tool_name: 'Write', tool_input: { file_path: '/src/damp-b.js', content: 'x' } }).stdout);
|
||||||
|
const firstReason = first.hookSpecificOutput.permissionDecisionReason;
|
||||||
|
const secondReason = second.hookSpecificOutput.permissionDecisionReason;
|
||||||
|
assert.ok(firstReason.includes('denial #6'), `expected ordinal 6, got: ${firstReason}`);
|
||||||
|
assert.ok(secondReason.includes('denial #7'), `expected ordinal 7, got: ${secondReason}`);
|
||||||
|
assert.notStrictEqual(firstReason, secondReason, 'successive denials must differ so they cannot compound verbatim');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
clearState();
|
||||||
|
if (test('retry of the same target is still allowed after a condensed denial', () => {
|
||||||
|
writeState({ checked: [], last_active: Date.now(), fact_force_denials: 9 });
|
||||||
|
const input = { tool_name: 'Edit', tool_input: { file_path: '/src/damp-retry.js' } };
|
||||||
|
const denied = parseOutput(runHook(input).stdout);
|
||||||
|
assert.strictEqual(denied.hookSpecificOutput.permissionDecision, 'deny');
|
||||||
|
const retryOutput = parseOutput(runHook(input).stdout);
|
||||||
|
assert.ok(!retryOutput || !retryOutput.hookSpecificOutput, 'retry passes through (no second deny, no re-prompt)');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
clearState();
|
||||||
|
if (test('GATEGUARD_FACT_FORCE_FULL_DENIALS tunes the full-block budget', () => {
|
||||||
|
// Budget 0: condensed from the very first denial.
|
||||||
|
const zero = parseOutput(runHook(
|
||||||
|
{ tool_name: 'Edit', tool_input: { file_path: '/src/damp-zero.js' } },
|
||||||
|
{ GATEGUARD_FACT_FORCE_FULL_DENIALS: '0' }
|
||||||
|
).stdout);
|
||||||
|
assert.ok(zero.hookSpecificOutput.permissionDecisionReason.includes('denial #1'));
|
||||||
|
assert.ok(!zero.hookSpecificOutput.permissionDecisionReason.includes('present these facts'));
|
||||||
|
|
||||||
|
// Large budget: full block well past the default threshold.
|
||||||
|
clearState();
|
||||||
|
writeState({ checked: [], last_active: Date.now(), fact_force_denials: 7 });
|
||||||
|
const big = parseOutput(runHook(
|
||||||
|
{ tool_name: 'Edit', tool_input: { file_path: '/src/damp-big.js' } },
|
||||||
|
{ GATEGUARD_FACT_FORCE_FULL_DENIALS: '20' }
|
||||||
|
).stdout);
|
||||||
|
assert.ok(big.hookSpecificOutput.permissionDecisionReason.includes('present these facts'),
|
||||||
|
'budget of 20 keeps the full block at denial 8');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
clearState();
|
||||||
|
if (test('malformed denial counter in state is treated as zero (full block, no crash)', () => {
|
||||||
|
writeState({ checked: [], last_active: Date.now(), fact_force_denials: 'garbage' });
|
||||||
|
const result = runHook({ tool_name: 'Edit', tool_input: { file_path: '/src/damp-malformed.js' } });
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const output = parseOutput(result.stdout);
|
||||||
|
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||||
|
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('present these facts'),
|
||||||
|
'malformed counter resets to the full block');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
clearState();
|
||||||
|
if (test('MultiEdit denials are dampened past the budget', () => {
|
||||||
|
writeState({ checked: [], last_active: Date.now(), fact_force_denials: 4 });
|
||||||
|
const result = runHook({
|
||||||
|
tool_name: 'MultiEdit',
|
||||||
|
tool_input: { edits: [{ file_path: '/src/damp-multi.js', old_string: 'a', new_string: 'b' }] }
|
||||||
|
});
|
||||||
|
const output = parseOutput(result.stdout);
|
||||||
|
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||||
|
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('denial #5'));
|
||||||
|
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('present these facts'));
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
clearState();
|
||||||
|
if (test('destructive Bash gate keeps the full message regardless of denial count', () => {
|
||||||
|
writeState({ checked: ['__bash_session__'], last_active: Date.now(), fact_force_denials: 50 });
|
||||||
|
const result = runBashHook({ tool_name: 'Bash', tool_input: { command: 'rm -rf /tmp/damp-target' } });
|
||||||
|
const output = parseOutput(result.stdout);
|
||||||
|
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||||
|
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('rollback'),
|
||||||
|
'destructive gate is exempt from dampening');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
// Cleanup only the temp directory created by this test file.
|
// Cleanup only the temp directory created by this test file.
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(stateDir)) {
|
if (fs.existsSync(stateDir)) {
|
||||||
|
|||||||
@@ -5285,17 +5285,15 @@ async function runTests() {
|
|||||||
console.log('\nRound 59: check-console-log.js (stdin exceeding 1MB — truncation):');
|
console.log('\nRound 59: check-console-log.js (stdin exceeding 1MB — truncation):');
|
||||||
|
|
||||||
if (
|
if (
|
||||||
await asyncTest('truncates stdin at 1MB limit and still passes through data', async () => {
|
await asyncTest('suppresses pass-through for oversized stdin (fail-open, #2090)', async () => {
|
||||||
// Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit
|
// Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit. Echoing the
|
||||||
|
// truncated string would emit a JSON document cut mid-stream, which the
|
||||||
|
// harness reports as a Stop hook JSON validation failure.
|
||||||
const payload = 'x'.repeat(1024 * 1024 + 200000);
|
const payload = 'x'.repeat(1024 * 1024 + 200000);
|
||||||
const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), payload);
|
const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), payload);
|
||||||
|
|
||||||
assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin');
|
assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin');
|
||||||
// Output should be truncated — significantly less than input
|
assert.strictEqual(result.stdout, '', 'Truncated stdin must not be echoed (empty stdout = no opinion)');
|
||||||
assert.ok(result.stdout.length < payload.length, `stdout (${result.stdout.length}) should be shorter than input (${payload.length})`);
|
|
||||||
// Output should be approximately 1MB (last accepted chunk may push slightly over)
|
|
||||||
assert.ok(result.stdout.length <= 1024 * 1024 + 65536, `stdout (${result.stdout.length}) should be near 1MB, not unbounded`);
|
|
||||||
assert.ok(result.stdout.length > 0, 'Should still pass through truncated data');
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
passed++;
|
passed++;
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Regression tests for #2222: run-with-flags.js must fail open on >1MB stdin.
|
||||||
|
*
|
||||||
|
* Before the fix, every fallthrough path echoed the truncated payload to
|
||||||
|
* stdout. The harness parses hook stdout as JSON, got a document cut
|
||||||
|
* mid-stream, and treated the hook as failed — blocking every Edit/Write
|
||||||
|
* whose hook payload exceeded the 1MB cap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const repoRoot = path.join(__dirname, '..', '..');
|
||||||
|
const runner = path.join(repoRoot, 'scripts', 'hooks', 'run-with-flags.js');
|
||||||
|
|
||||||
|
const MAX_STDIN = 1024 * 1024;
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` ✓ ${name}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ✗ ${name}`);
|
||||||
|
console.log(` Error: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runRunner(args, input, env = {}) {
|
||||||
|
return spawnSync('node', [runner, ...args], {
|
||||||
|
input,
|
||||||
|
encoding: 'utf8',
|
||||||
|
cwd: repoRoot,
|
||||||
|
env: { ...process.env, ...env },
|
||||||
|
timeout: 30000,
|
||||||
|
maxBuffer: 16 * 1024 * 1024,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function oversizedPayload() {
|
||||||
|
// JSON document that exceeds MAX_STDIN so the runner's stdin cap trips.
|
||||||
|
return JSON.stringify({
|
||||||
|
tool_name: 'Write',
|
||||||
|
tool_input: { file_path: '/tmp/big.md', content: 'x'.repeat(MAX_STDIN + 64 * 1024) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nrun-with-flags truncation (fail-open) tests:');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('oversized payload exits 0 with empty stdout for an enabled hook', () => {
|
||||||
|
const result = runRunner(['pre:write:doc-file-warning', 'scripts/hooks/doc-file-warning.js', 'standard,strict'], oversizedPayload());
|
||||||
|
assert.strictEqual(result.status, 0, `expected exit 0, got ${result.status}: ${result.stderr}`);
|
||||||
|
assert.strictEqual(result.stdout, '', `stdout must be empty, got: ${result.stdout.slice(0, 120)}...`);
|
||||||
|
assert.match(result.stderr, /stdin exceeded \d+ bytes for pre:write:doc-file-warning/);
|
||||||
|
assert.match(result.stderr, /fail-open/);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('oversized payload never echoes truncated stdin when hook args are missing', () => {
|
||||||
|
const result = runRunner([], oversizedPayload());
|
||||||
|
assert.strictEqual(result.status, 0);
|
||||||
|
assert.strictEqual(result.stdout, '', 'missing-args path must not echo truncated stdin');
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('oversized payload never echoes truncated stdin for a disabled hook', () => {
|
||||||
|
const result = runRunner(['pre:write:doc-file-warning', 'scripts/hooks/doc-file-warning.js', 'standard,strict'], oversizedPayload(), { ECC_DISABLED_HOOKS: 'pre:write:doc-file-warning' });
|
||||||
|
assert.strictEqual(result.status, 0);
|
||||||
|
assert.strictEqual(result.stdout, '', 'disabled-hook path must not echo truncated stdin');
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('normal-sized payload still passes through unchanged', () => {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
tool_name: 'Write',
|
||||||
|
tool_input: { file_path: '/tmp/small.js', content: 'const x = 1;\n' }
|
||||||
|
});
|
||||||
|
const result = runRunner(['pre:write:doc-file-warning', 'scripts/hooks/doc-file-warning.js', 'standard,strict'], payload);
|
||||||
|
assert.strictEqual(result.status, 0, `expected exit 0, got ${result.status}: ${result.stderr}`);
|
||||||
|
assert.ok(result.stdout.length > 0, 'normal payloads keep the pass-through behavior');
|
||||||
|
JSON.parse(result.stdout); // stdout must remain valid JSON
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('a security hook can still block on an oversized payload (no blanket skip)', () => {
|
||||||
|
// config-protection refuses to fail open on truncated payloads. The
|
||||||
|
// runner must still execute the hook and forward its verdict — only the
|
||||||
|
// runner's own raw-echo is suppressed.
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
tool_name: 'Write',
|
||||||
|
tool_input: { file_path: '.eslintrc.js', content: 'x'.repeat(MAX_STDIN + 2048) }
|
||||||
|
});
|
||||||
|
const result = runRunner(['pre:config-protection', 'scripts/hooks/config-protection.js', 'standard,strict'], payload);
|
||||||
|
assert.strictEqual(result.status, 2, `expected block exit 2, got ${result.status}: ${result.stderr}`);
|
||||||
|
assert.strictEqual(result.stdout, '', 'blocked truncated payload must not echo raw input');
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('payload just under the cap echoes through completely (no 64KB pipe cut)', () => {
|
||||||
|
// process.exit() right after stdout.write() used to drop everything past
|
||||||
|
// the ~64KB pipe buffer, cutting the echoed JSON mid-stream.
|
||||||
|
const content = 'y'.repeat(MAX_STDIN - 1024);
|
||||||
|
const payload = JSON.stringify({ tool_name: 'Write', tool_input: { file_path: '/tmp/edge.md', content } });
|
||||||
|
assert.ok(payload.length < MAX_STDIN, 'fixture must stay under the stdin cap');
|
||||||
|
const result = runRunner([], payload);
|
||||||
|
assert.strictEqual(result.status, 0);
|
||||||
|
assert.strictEqual(result.stdout.length, payload.length, 'echo must not be cut at the pipe buffer');
|
||||||
|
assert.strictEqual(result.stdout, payload, 'sub-cap payloads still echo through fallthrough paths');
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('disabled-hook passthrough of a >64KB payload stays valid JSON', () => {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
tool_name: 'Write',
|
||||||
|
tool_input: { file_path: '/tmp/medium.md', content: 'z'.repeat(256 * 1024) }
|
||||||
|
});
|
||||||
|
const result = runRunner(['pre:write:doc-file-warning', 'scripts/hooks/doc-file-warning.js', 'standard,strict'], payload, { ECC_DISABLED_HOOKS: 'pre:write:doc-file-warning' });
|
||||||
|
assert.strictEqual(result.status, 0);
|
||||||
|
assert.strictEqual(result.stdout, payload);
|
||||||
|
JSON.parse(result.stdout);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
console.log(`\n ${passed} passed, ${failed} failed\n`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Regression tests for #2090: "Stop hook error: JSON validation failed".
|
||||||
|
*
|
||||||
|
* Stop hooks follow the ECC pass-through convention (echo stdin on stdout).
|
||||||
|
* The Stop payload carries `last_assistant_message`, which can be large; any
|
||||||
|
* hook that caps stdin and echoes the capped string emits a JSON document cut
|
||||||
|
* mid-stream, which the harness reports as a Stop hook JSON validation
|
||||||
|
* failure. Worst offender: cost-tracker capped stdin at 64KB, so any Stop
|
||||||
|
* payload with a >64KB final assistant message broke the whole Stop chain.
|
||||||
|
*
|
||||||
|
* Contract under test: for every Stop hook, stdout is either empty or valid
|
||||||
|
* JSON, and the exit code is 0 — for realistic large payloads and for
|
||||||
|
* oversized (>1MB) payloads, via the production runner and via direct
|
||||||
|
* invocation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const repoRoot = path.join(__dirname, '..', '..');
|
||||||
|
const runner = path.join(repoRoot, 'scripts', 'hooks', 'run-with-flags.js');
|
||||||
|
|
||||||
|
const MAX_STDIN = 1024 * 1024;
|
||||||
|
|
||||||
|
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-stop-stdout-')); // non-git cwd
|
||||||
|
const dataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-stop-data-'));
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` ✓ ${name}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ✗ ${name}`);
|
||||||
|
console.log(` Error: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPayload(messageBytes) {
|
||||||
|
return JSON.stringify({
|
||||||
|
session_id: `stop-stdout-test-${process.pid}`,
|
||||||
|
transcript_path: path.join(workDir, 'missing-transcript.jsonl'),
|
||||||
|
cwd: workDir,
|
||||||
|
hook_event_name: 'Stop',
|
||||||
|
stop_hook_active: false,
|
||||||
|
last_assistant_message: 'm'.repeat(messageBytes)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hookEnv() {
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
ECC_HOOK_PROFILE: 'standard',
|
||||||
|
ECC_AGENT_DATA_HOME: dataHome,
|
||||||
|
CLAUDE_SESSION_ID: `stop-stdout-test-${process.pid}`
|
||||||
|
};
|
||||||
|
delete env.ECC_GATEGUARD;
|
||||||
|
delete env.ECC_DISABLED_HOOKS;
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runViaRunner(hookId, script, input) {
|
||||||
|
return spawnSync('node', [runner, hookId, script, 'minimal,standard,strict'], {
|
||||||
|
input,
|
||||||
|
encoding: 'utf8',
|
||||||
|
cwd: workDir,
|
||||||
|
env: hookEnv(),
|
||||||
|
timeout: 60000,
|
||||||
|
maxBuffer: 16 * 1024 * 1024,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDirect(script, input) {
|
||||||
|
return spawnSync('node', [path.join(repoRoot, script)], {
|
||||||
|
input,
|
||||||
|
encoding: 'utf8',
|
||||||
|
cwd: workDir,
|
||||||
|
env: hookEnv(),
|
||||||
|
timeout: 60000,
|
||||||
|
maxBuffer: 16 * 1024 * 1024,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertStdoutContract(result, label) {
|
||||||
|
assert.strictEqual(result.status, 0, `${label}: expected exit 0, got ${result.status}: ${result.stderr}`);
|
||||||
|
if (result.stdout.length > 0) {
|
||||||
|
try {
|
||||||
|
JSON.parse(result.stdout);
|
||||||
|
} catch (err) {
|
||||||
|
assert.fail(`${label}: stdout is non-empty but not valid JSON (${err.message}); first 120 chars: ${result.stdout.slice(0, 120)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All registered Stop hooks (hooks/hooks.json).
|
||||||
|
const STOP_HOOKS = [
|
||||||
|
['stop:format-typecheck', 'scripts/hooks/stop-format-typecheck.js'],
|
||||||
|
['stop:check-console-log', 'scripts/hooks/check-console-log.js'],
|
||||||
|
['stop:session-end', 'scripts/hooks/session-end.js'],
|
||||||
|
['stop:evaluate-session', 'scripts/hooks/evaluate-session.js'],
|
||||||
|
['stop:cost-tracker', 'scripts/hooks/cost-tracker.js']
|
||||||
|
// stop:desktop-notify is excluded from the valid-payload run because a
|
||||||
|
// successful run() fires a real OS notification; its truncation path is
|
||||||
|
// covered separately below (run() bails on JSON.parse before notifying).
|
||||||
|
];
|
||||||
|
|
||||||
|
// Direct-invocation legacy paths that echo stdin.
|
||||||
|
const ECHOING_STOP_HOOKS = [
|
||||||
|
'scripts/hooks/stop-format-typecheck.js',
|
||||||
|
'scripts/hooks/check-console-log.js',
|
||||||
|
'scripts/hooks/cost-tracker.js',
|
||||||
|
'scripts/hooks/desktop-notify.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('\nStop hook stdout contract tests (#2090):');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
// A 100KB last_assistant_message is a realistic long-session Stop payload.
|
||||||
|
// Before the fix, cost-tracker echoed it cut at 64KB through the production
|
||||||
|
// runner path, making the harness report "JSON validation failed".
|
||||||
|
const realisticPayload = stopPayload(100 * 1024);
|
||||||
|
|
||||||
|
for (const [hookId, script] of STOP_HOOKS) {
|
||||||
|
if (
|
||||||
|
test(`${hookId} via runner keeps stdout valid for a 100KB Stop payload`, () => {
|
||||||
|
const result = runViaRunner(hookId, script, realisticPayload);
|
||||||
|
assertStdoutContract(result, hookId);
|
||||||
|
if (result.stdout.length > 0) {
|
||||||
|
assert.strictEqual(result.stdout, realisticPayload, `${hookId}: pass-through must echo the payload uncut`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oversizedPayload = stopPayload(MAX_STDIN + 64 * 1024);
|
||||||
|
|
||||||
|
for (const [hookId, script] of [...STOP_HOOKS, ['stop:desktop-notify', 'scripts/hooks/desktop-notify.js']]) {
|
||||||
|
if (
|
||||||
|
test(`${hookId} via runner fails open on a >1MB Stop payload`, () => {
|
||||||
|
const result = runViaRunner(hookId, script, oversizedPayload);
|
||||||
|
assert.strictEqual(result.status, 0, `${hookId}: expected exit 0, got ${result.status}: ${result.stderr}`);
|
||||||
|
assert.strictEqual(result.stdout, '', `${hookId}: oversized payloads must not be echoed`);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const script of ECHOING_STOP_HOOKS) {
|
||||||
|
if (
|
||||||
|
test(`${path.basename(script)} invoked directly never echoes truncated stdin`, () => {
|
||||||
|
const result = runDirect(script, oversizedPayload);
|
||||||
|
assert.strictEqual(result.status, 0, `${script}: expected exit 0, got ${result.status}: ${result.stderr}`);
|
||||||
|
assert.strictEqual(result.stdout, '', `${script}: truncated stdin must not be echoed`);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('check-console-log invoked directly echoes a sub-cap >64KB payload uncut', () => {
|
||||||
|
const result = runDirect('scripts/hooks/check-console-log.js', realisticPayload);
|
||||||
|
assert.strictEqual(result.status, 0);
|
||||||
|
assert.strictEqual(result.stdout, realisticPayload, 'pass-through must not be cut at the pipe buffer');
|
||||||
|
JSON.parse(result.stdout);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('cost-tracker invoked directly echoes a sub-cap >64KB payload uncut', () => {
|
||||||
|
const result = runDirect('scripts/hooks/cost-tracker.js', realisticPayload);
|
||||||
|
assert.strictEqual(result.status, 0);
|
||||||
|
assert.strictEqual(result.stdout, realisticPayload, 'the old 64KB cap must not cut realistic Stop payloads');
|
||||||
|
JSON.parse(result.stdout);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.rmSync(workDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(dataHome, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
/* best-effort cleanup */
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n ${passed} passed, ${failed} failed\n`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
@@ -33,10 +33,18 @@ function test(name, fn) {
|
|||||||
* Returns { code, stdout, stderr }.
|
* Returns { code, stdout, stderr }.
|
||||||
*/
|
*/
|
||||||
function runCompact(envOverrides = {}) {
|
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 env = { ...process.env, ...envOverrides };
|
||||||
const result = spawnSync('node', [compactScript], {
|
const result = spawnSync('node', [compactScript], {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
input: '{}',
|
input: typeof input === 'string' ? input : JSON.stringify(input),
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
env,
|
env,
|
||||||
});
|
});
|
||||||
@@ -637,6 +645,252 @@ function runTests() {
|
|||||||
})) passed++;
|
})) passed++;
|
||||||
else failed++;
|
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
|
// Summary
|
||||||
console.log(`
|
console.log(`
|
||||||
Results: Passed: ${passed}, Failed: ${failed}`);
|
Results: Passed: ${passed}, Failed: ${failed}`);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -393,7 +393,11 @@ test('marketplace.json plugin version matches package.json', () => {
|
|||||||
assert.strictEqual(marketplace.plugins[0].version, expectedVersion);
|
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) {
|
for (const plugin of marketplace.plugins) {
|
||||||
if (!plugin.source || plugin.source.source !== 'local') {
|
if (!plugin.source || plugin.source.source !== 'local') {
|
||||||
continue;
|
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}`);
|
assert.ok(plugin.source.path.startsWith('./'), `Codex marketplace source.path must be ./-prefixed: ${plugin.source.path}`);
|
||||||
const resolvedRoot = path.resolve(repoRoot, 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.notStrictEqual(resolvedRoot, repoRoot, `Codex never discovers "./" marketplace roots — source.path must target a plugin subdirectory (#2128), 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(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, '.mcp.json')), `Root MCP config missing under resolved marketplace root: ${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', () => {
|
test('.opencode/package.json version matches package.json', () => {
|
||||||
assert.strictEqual(opencodePackage.version, expectedVersion);
|
assert.strictEqual(opencodePackage.version, expectedVersion);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ if (
|
|||||||
else failed++;
|
else failed++;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
test('merge-mcp-config dry-run appends all recommended servers without mutating target', () => {
|
test('merge-mcp-config dry-run appends the current default set without mutating target', () => {
|
||||||
const tempDir = createTempDir('mcp-merge-dry-run-');
|
const tempDir = createTempDir('mcp-merge-dry-run-');
|
||||||
const configPath = path.join(tempDir, 'config.toml');
|
const configPath = path.join(tempDir, 'config.toml');
|
||||||
const original = '';
|
const original = '';
|
||||||
@@ -272,9 +272,12 @@ if (
|
|||||||
|
|
||||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||||
assert.match(result.stdout, /Package manager: npm \(exec: npx\)/);
|
assert.match(result.stdout, /Package manager: npm \(exec: npx\)/);
|
||||||
assert.match(result.stdout, /\[add\] mcp_servers\.supabase/);
|
assert.match(result.stdout, /\[add\] mcp_servers\.chrome-devtools/);
|
||||||
assert.match(result.stdout, /\[mcp_servers\.github\]/);
|
assert.match(result.stdout, /\[mcp_servers\.chrome-devtools\]/);
|
||||||
assert.match(result.stdout, /Dry run/);
|
assert.match(result.stdout, /Dry run/);
|
||||||
|
// Retired defaults (June 2026 connector policy) must not be emitted.
|
||||||
|
assert.doesNotMatch(result.stdout, /mcp_servers\.(supabase|playwright|context7|exa|github|memory|sequential-thinking)\b/);
|
||||||
|
assert.doesNotMatch(result.stdout, /url = /);
|
||||||
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original);
|
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original);
|
||||||
} finally {
|
} finally {
|
||||||
cleanup(tempDir);
|
cleanup(tempDir);
|
||||||
@@ -296,14 +299,17 @@ if (
|
|||||||
|
|
||||||
const merged = fs.readFileSync(configPath, 'utf8');
|
const merged = fs.readFileSync(configPath, 'utf8');
|
||||||
const parsed = TOML.parse(merged);
|
const parsed = TOML.parse(merged);
|
||||||
assert.strictEqual(parsed.mcp_servers.exa.url, 'https://mcp.exa.ai/mcp');
|
assert.strictEqual(parsed.mcp_servers['chrome-devtools'].command, 'npx');
|
||||||
assert.strictEqual(parsed.mcp_servers.github.command, 'bash');
|
assert.deepStrictEqual(parsed.mcp_servers['chrome-devtools'].args, ['chrome-devtools-mcp@latest']);
|
||||||
assert.deepStrictEqual(parsed.mcp_servers.memory.args, ['@modelcontextprotocol/server-memory']);
|
assert.strictEqual(parsed.mcp_servers['chrome-devtools'].startup_timeout_sec, 30);
|
||||||
assert.strictEqual(parsed.mcp_servers.supabase.tool_timeout_sec, 120);
|
// No retired server may be (re-)emitted — exa's url form broke Codex (#2224).
|
||||||
|
assert.strictEqual(parsed.mcp_servers.exa, undefined);
|
||||||
|
assert.strictEqual(parsed.mcp_servers.github, undefined);
|
||||||
|
assert.strictEqual(parsed.mcp_servers.supabase, undefined);
|
||||||
|
|
||||||
const second = runNode(mergeMcpConfigScript, [configPath], deterministicPackageEnv);
|
const second = runNode(mergeMcpConfigScript, [configPath], deterministicPackageEnv);
|
||||||
assert.strictEqual(second.status, 0, `${second.stdout}\n${second.stderr}`);
|
assert.strictEqual(second.status, 0, `${second.stdout}\n${second.stderr}`);
|
||||||
assert.match(second.stdout, /\[ok\] mcp_servers\.github/);
|
assert.match(second.stdout, /\[ok\] mcp_servers\.chrome-devtools/);
|
||||||
assert.match(second.stdout, /All ECC MCP servers already present/);
|
assert.match(second.stdout, /All ECC MCP servers already present/);
|
||||||
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), merged);
|
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), merged);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -315,24 +321,88 @@ if (
|
|||||||
else failed++;
|
else failed++;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
test('merge-mcp-config update dry-run reports canonical and legacy section refreshes', () => {
|
test('merge-mcp-config repairs the invalid exa url entry from earlier ECC versions (#2224)', () => {
|
||||||
|
const tempDir = createTempDir('mcp-merge-exa-repair-');
|
||||||
|
const configPath = path.join(tempDir, 'config.toml');
|
||||||
|
const original = [
|
||||||
|
'[mcp_servers.github]',
|
||||||
|
'command = "npx"',
|
||||||
|
'args = ["-y", "@modelcontextprotocol/server-github"]',
|
||||||
|
'',
|
||||||
|
'[mcp_servers.exa]',
|
||||||
|
'url = "https://mcp.exa.ai/mcp"',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(configPath, original);
|
||||||
|
const result = runNode(mergeMcpConfigScript, [configPath], deterministicPackageEnv);
|
||||||
|
|
||||||
|
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||||
|
assert.match(result.stdout, /\[repair\] mcp_servers\.exa/);
|
||||||
|
|
||||||
|
const updated = fs.readFileSync(configPath, 'utf8');
|
||||||
|
const parsed = TOML.parse(updated);
|
||||||
|
assert.strictEqual(parsed.mcp_servers.exa, undefined, 'invalid exa url entry must be removed');
|
||||||
|
assert.doesNotMatch(updated, /url = "https:\/\/mcp\.exa\.ai\/mcp"/);
|
||||||
|
// User-managed servers are untouched; current default is added.
|
||||||
|
assert.strictEqual(parsed.mcp_servers.github.command, 'npx');
|
||||||
|
assert.strictEqual(parsed.mcp_servers['chrome-devtools'].command, 'npx');
|
||||||
|
|
||||||
|
// Re-running must not re-introduce the invalid entry.
|
||||||
|
const second = runNode(mergeMcpConfigScript, [configPath], deterministicPackageEnv);
|
||||||
|
assert.strictEqual(second.status, 0, `${second.stdout}\n${second.stderr}`);
|
||||||
|
assert.doesNotMatch(fs.readFileSync(configPath, 'utf8'), /mcp_servers\.exa/);
|
||||||
|
} finally {
|
||||||
|
cleanup(tempDir);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('merge-mcp-config leaves a user-managed stdio exa entry untouched', () => {
|
||||||
|
const tempDir = createTempDir('mcp-merge-exa-stdio-');
|
||||||
|
const configPath = path.join(tempDir, 'config.toml');
|
||||||
|
const original = [
|
||||||
|
'[mcp_servers.exa]',
|
||||||
|
'command = "npx"',
|
||||||
|
'args = ["-y", "mcp-remote", "https://mcp.exa.ai/mcp"]',
|
||||||
|
'startup_timeout_sec = 30',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(configPath, original);
|
||||||
|
const result = runNode(mergeMcpConfigScript, [configPath], deterministicPackageEnv);
|
||||||
|
|
||||||
|
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||||
|
assert.doesNotMatch(result.stdout, /\[repair\]/);
|
||||||
|
|
||||||
|
const parsed = TOML.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
assert.strictEqual(parsed.mcp_servers.exa.command, 'npx');
|
||||||
|
assert.deepStrictEqual(parsed.mcp_servers.exa.args, ['-y', 'mcp-remote', 'https://mcp.exa.ai/mcp']);
|
||||||
|
} finally {
|
||||||
|
cleanup(tempDir);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('merge-mcp-config update dry-run refreshes managed sections and leaves user servers alone', () => {
|
||||||
const tempDir = createTempDir('mcp-merge-update-dry-run-');
|
const tempDir = createTempDir('mcp-merge-update-dry-run-');
|
||||||
const configPath = path.join(tempDir, 'config.toml');
|
const configPath = path.join(tempDir, 'config.toml');
|
||||||
const original = [
|
const original = [
|
||||||
|
'[mcp_servers.chrome-devtools]',
|
||||||
|
'command = "custom"',
|
||||||
|
'args = ["old"]',
|
||||||
|
'',
|
||||||
'[mcp_servers.context7]',
|
'[mcp_servers.context7]',
|
||||||
'command = "custom"',
|
|
||||||
'args = ["old"]',
|
|
||||||
'',
|
|
||||||
'[mcp_servers.context7-mcp]',
|
|
||||||
'command = "npx"',
|
'command = "npx"',
|
||||||
'args = ["legacy"]',
|
'args = ["-y", "@upstash/context7-mcp@latest"]',
|
||||||
'',
|
|
||||||
'[mcp_servers.supabase]',
|
|
||||||
'command = "custom"',
|
|
||||||
'args = ["old"]',
|
|
||||||
'',
|
|
||||||
'[mcp_servers.supabase.env]',
|
|
||||||
'SUPABASE_ACCESS_TOKEN = "token"',
|
|
||||||
'',
|
'',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
@@ -341,11 +411,10 @@ if (
|
|||||||
const result = runNode(mergeMcpConfigScript, [configPath, '--update-mcp', '--dry-run'], deterministicPackageEnv);
|
const result = runNode(mergeMcpConfigScript, [configPath, '--update-mcp', '--dry-run'], deterministicPackageEnv);
|
||||||
|
|
||||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||||
assert.match(result.stdout, /\[remove\] mcp_servers\.context7/);
|
assert.match(result.stdout, /\[remove\] mcp_servers\.chrome-devtools/);
|
||||||
assert.match(result.stdout, /\[remove\] mcp_servers\.context7-mcp/);
|
assert.match(result.stdout, /\[mcp_servers\.chrome-devtools\]/);
|
||||||
assert.match(result.stdout, /\[remove\] mcp_servers\.supabase/);
|
// Retired servers are no longer ECC-managed: never removed or re-added.
|
||||||
assert.match(result.stdout, /\[mcp_servers\.supabase\]/);
|
assert.doesNotMatch(result.stdout, /\[remove\] mcp_servers\.context7/);
|
||||||
assert.match(result.stdout, /\[mcp_servers\.context7\]/);
|
|
||||||
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original);
|
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original);
|
||||||
} finally {
|
} finally {
|
||||||
cleanup(tempDir);
|
cleanup(tempDir);
|
||||||
@@ -356,38 +425,31 @@ if (
|
|||||||
else failed++;
|
else failed++;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
test('merge-mcp-config removes disabled legacy servers without appending replacements', () => {
|
test('merge-mcp-config removes disabled servers without appending replacements', () => {
|
||||||
const tempDir = createTempDir('mcp-merge-disabled-');
|
const tempDir = createTempDir('mcp-merge-disabled-');
|
||||||
const configPath = path.join(tempDir, 'config.toml');
|
const configPath = path.join(tempDir, 'config.toml');
|
||||||
const original = [
|
const original = [
|
||||||
'[mcp_servers.context7-mcp]',
|
'[mcp_servers.chrome-devtools]',
|
||||||
'command = "npx"',
|
'command = "npx"',
|
||||||
'args = ["legacy"]',
|
'args = ["chrome-devtools-mcp@latest"]',
|
||||||
'',
|
|
||||||
'[mcp_servers.exa]',
|
|
||||||
'url = "https://mcp.exa.ai/mcp"',
|
|
||||||
'',
|
'',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
const allServersDisabled = 'supabase,playwright,context7,exa,github,memory,sequential-thinking';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(configPath, original);
|
fs.writeFileSync(configPath, original);
|
||||||
const result = runNode(mergeMcpConfigScript, [configPath], {
|
const result = runNode(mergeMcpConfigScript, [configPath], {
|
||||||
...deterministicPackageEnv,
|
...deterministicPackageEnv,
|
||||||
ECC_DISABLED_MCPS: allServersDisabled,
|
ECC_DISABLED_MCPS: 'chrome-devtools',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||||
assert.match(result.stdout, /Disabled via ECC_DISABLED_MCPS/);
|
assert.match(result.stdout, /Disabled via ECC_DISABLED_MCPS/);
|
||||||
assert.match(result.stdout, /\[skip\] mcp_servers\.context7 \(disabled\)/);
|
assert.match(result.stdout, /\[skip\] mcp_servers\.chrome-devtools \(disabled\)/);
|
||||||
assert.match(result.stdout, /\[skip\] mcp_servers\.exa \(disabled\)/);
|
assert.match(result.stdout, /\[update\] mcp_servers\.chrome-devtools \(disabled\)/);
|
||||||
assert.match(result.stdout, /\[update\] mcp_servers\.context7-mcp \(disabled\)/);
|
assert.match(result.stdout, /Done\. Removed 1 server section\(s\)\./);
|
||||||
assert.match(result.stdout, /\[update\] mcp_servers\.exa \(disabled\)/);
|
|
||||||
assert.match(result.stdout, /Done\. Removed 2 disabled server\(s\)\./);
|
|
||||||
|
|
||||||
const updated = fs.readFileSync(configPath, 'utf8');
|
const updated = fs.readFileSync(configPath, 'utf8');
|
||||||
assert.doesNotMatch(updated, /context7-mcp/);
|
assert.doesNotMatch(updated, /chrome-devtools/);
|
||||||
assert.doesNotMatch(updated, /mcp_servers\.exa/);
|
|
||||||
} finally {
|
} finally {
|
||||||
cleanup(tempDir);
|
cleanup(tempDir);
|
||||||
}
|
}
|
||||||
@@ -454,7 +516,10 @@ if (
|
|||||||
assert.strictEqual(parsedConfig.agents.explorer.config_file, 'agents/explorer.toml');
|
assert.strictEqual(parsedConfig.agents.explorer.config_file, 'agents/explorer.toml');
|
||||||
assert.strictEqual(parsedConfig.agents.reviewer.config_file, 'agents/reviewer.toml');
|
assert.strictEqual(parsedConfig.agents.reviewer.config_file, 'agents/reviewer.toml');
|
||||||
assert.strictEqual(parsedConfig.agents.docs_researcher.config_file, 'agents/docs-researcher.toml');
|
assert.strictEqual(parsedConfig.agents.docs_researcher.config_file, 'agents/docs-researcher.toml');
|
||||||
assert.ok(parsedConfig.mcp_servers.exa);
|
// Current default connector is added; retired servers are not emitted,
|
||||||
|
// and pre-existing user-managed entries are preserved untouched.
|
||||||
|
assert.ok(parsedConfig.mcp_servers['chrome-devtools']);
|
||||||
|
assert.strictEqual(parsedConfig.mcp_servers.exa, undefined);
|
||||||
assert.ok(parsedConfig.mcp_servers.github);
|
assert.ok(parsedConfig.mcp_servers.github);
|
||||||
assert.ok(parsedConfig.mcp_servers.memory);
|
assert.ok(parsedConfig.mcp_servers.memory);
|
||||||
assert.ok(parsedConfig.mcp_servers['sequential-thinking']);
|
assert.ok(parsedConfig.mcp_servers['sequential-thinking']);
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ function buildExpectedPublishPaths(repoRoot) {
|
|||||||
"scripts/codex/merge-codex-config.js",
|
"scripts/codex/merge-codex-config.js",
|
||||||
"scripts/codex/merge-mcp-config.js",
|
"scripts/codex/merge-mcp-config.js",
|
||||||
".codex-plugin",
|
".codex-plugin",
|
||||||
|
"plugins/ecc",
|
||||||
".mcp.json",
|
".mcp.json",
|
||||||
"install.sh",
|
"install.sh",
|
||||||
"install.ps1",
|
"install.ps1",
|
||||||
@@ -143,6 +144,7 @@ function main() {
|
|||||||
".qwen/QWEN.md",
|
".qwen/QWEN.md",
|
||||||
".claude-plugin/plugin.json",
|
".claude-plugin/plugin.json",
|
||||||
".codex-plugin/plugin.json",
|
".codex-plugin/plugin.json",
|
||||||
|
"plugins/ecc/.codex-plugin/plugin.json",
|
||||||
"assets/ecc-icon.svg",
|
"assets/ecc-icon.svg",
|
||||||
"assets/hero.png",
|
"assets/hero.png",
|
||||||
"schemas/install-state.schema.json",
|
"schemas/install-state.schema.json",
|
||||||
|
|||||||
Reference in New Issue
Block a user