Compare commits

..

2 Commits

Author SHA1 Message Date
Affaan Mustafa e555c6a4b7 wip: checkpoint ecc2 board observability prototype 2026-04-17 22:37:38 -04:00
Affaan Mustafa 463eb5fb41 docs: add ecc recovery guidance for wiped setups 2026-04-09 18:12:08 -07:00
63 changed files with 2342 additions and 39249 deletions
-1
View File
@@ -6,7 +6,6 @@
"plugins": [
{
"name": "ecc",
"version": "1.10.0",
"source": {
"source": "local",
"path": "../.."
+2 -2
View File
@@ -1,5 +1,5 @@
{
"name": "everything-claude-code",
"name": "ecc",
"owner": {
"name": "Affaan Mustafa",
"email": "me@affaanmustafa.com"
@@ -9,7 +9,7 @@
},
"plugins": [
{
"name": "everything-claude-code",
"name": "ecc",
"source": "./",
"description": "The most comprehensive Claude Code plugin — 38 agents, 156 skills, 72 legacy command shims, selective install profiles, and production-ready hooks for TDD, security scanning, code review, and continuous learning",
"version": "1.10.0",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "everything-claude-code",
"name": "ecc",
"version": "1.10.0",
"description": "Battle-tested Claude Code plugin for engineering teams — 38 agents, 156 skills, 72 legacy command shims, production-ready hooks, and selective install workflows evolved through continuous real-world use",
"author": {
-4
View File
@@ -190,10 +190,6 @@ jobs:
run: node scripts/ci/validate-install-manifests.js
continue-on-error: false
- name: Validate workflow security
run: node scripts/ci/validate-workflow-security.js
continue-on-error: false
- name: Validate rules
run: node scripts/ci/validate-rules.js
continue-on-error: false
+4 -7
View File
@@ -38,21 +38,18 @@ jobs:
env:
REF_NAME: ${{ github.ref_name }}
- name: Verify package version matches tag
- name: Verify plugin.json version matches tag
env:
TAG_NAME: ${{ github.ref_name }}
run: |
TAG_VERSION="${TAG_NAME#v}"
PACKAGE_VERSION=$(node -p "require('./package.json').version")
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
echo "::error::Tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_VERSION)"
PLUGIN_VERSION=$(grep -oE '"version": *"[^"]*"' .claude-plugin/plugin.json | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
if [ "$TAG_VERSION" != "$PLUGIN_VERSION" ]; then
echo "::error::Tag version ($TAG_VERSION) does not match plugin.json version ($PLUGIN_VERSION)"
echo "Run: ./scripts/release.sh $TAG_VERSION"
exit 1
fi
- name: Verify release metadata stays in sync
run: node tests/plugin-manifest.test.js
- name: Generate release highlights
id: highlights
env:
-15
View File
@@ -47,21 +47,6 @@ jobs:
exit 1
fi
- name: Verify package version matches tag
env:
INPUT_TAG: ${{ inputs.tag }}
run: |
TAG_VERSION="${INPUT_TAG#v}"
PACKAGE_VERSION=$(node -p "require('./package.json').version")
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
echo "::error::Tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_VERSION)"
echo "Run: ./scripts/release.sh $TAG_VERSION"
exit 1
fi
- name: Verify release metadata stays in sync
run: node tests/plugin-manifest.test.js
- name: Generate release highlights
env:
TAG_NAME: ${{ inputs.tag }}
-3
View File
@@ -42,9 +42,6 @@ jobs:
- name: Validate install manifests
run: node scripts/ci/validate-install-manifests.js
- name: Validate workflow security
run: node scripts/ci/validate-workflow-security.js
- name: Validate rules
run: node scripts/ci/validate-rules.js
+2 -2
View File
@@ -1,6 +1,6 @@
# Everything Claude Code (ECC) — Agent Instructions
This is a **production-ready AI coding plugin** providing 47 specialized agents, 181 skills, 80 commands, and automated hook workflows for software development.
This is a **production-ready AI coding plugin** providing 47 specialized agents, 181 skills, 79 commands, and automated hook workflows for software development.
**Version:** 1.10.0
@@ -147,7 +147,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
```
agents/ — 47 specialized subagents
skills/ — 181 workflow skills and domain knowledge
commands/ — 80 slash commands
commands/ — 79 slash commands
hooks/ — Trigger-based automations
rules/ — Always-follow guidelines (common + per-language)
scripts/ — Cross-platform Node.js utilities
+5 -7
View File
@@ -179,7 +179,7 @@ Get up and running in under 2 minutes:
### Step 2: Install Rules (Required)
> WARNING: **Important:** Claude Code plugins cannot distribute `rules` automatically. Install them manually:
>
> If your local Claude setup was wiped or reset, that does not mean you need to repurchase ECC. Start with `ecc list-installed`, then run `ecc doctor` and `ecc repair` before reinstalling anything. That usually restores ECC-managed files without rebuilding your setup. If the problem is account or marketplace access for ECC Tools, handle billing/account recovery separately.
```bash
@@ -238,7 +238,7 @@ For manual install instructions see the README in the `rules/` folder. When copy
/plugin list ecc@ecc
```
**That's it!** You now have access to 47 agents, 181 skills, and 80 legacy command shims.
**That's it!** You now have access to 47 agents, 181 skills, and 79 legacy command shims.
### Multi-model commands require additional setup
@@ -705,9 +705,7 @@ cp everything-claude-code/commands/*.md ~/.claude/commands/
#### Add hooks to settings.json
Manual install only: copy the hooks from `hooks/hooks.json` to your `~/.claude/settings.json` if you are not installing ECC as a Claude plugin.
If you installed ECC via `/plugin install`, do not copy those hooks into `settings.json`. Claude Code v2.1+ already auto-loads plugin `hooks/hooks.json`, and duplicating them in `settings.json` causes duplicate execution and `${CLAUDE_PLUGIN_ROOT}` resolution failures.
Copy the hooks from `hooks/hooks.json` to your `~/.claude/settings.json`.
#### Configure MCPs
@@ -1158,7 +1156,7 @@ The configuration is automatically detected from `.opencode/opencode.json`.
| Feature | Claude Code | OpenCode | Status |
|---------|-------------|----------|--------|
| Agents | PASS: 47 agents | PASS: 12 agents | **Claude Code leads** |
| Commands | PASS: 80 commands | PASS: 31 commands | **Claude Code leads** |
| Commands | PASS: 79 commands | PASS: 31 commands | **Claude Code leads** |
| Skills | PASS: 181 skills | PASS: 37 skills | **Claude Code leads** |
| Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** |
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
@@ -1267,7 +1265,7 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e
| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|---------|------------|------------|-----------|----------|
| **Agents** | 47 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |
| **Commands** | 80 | Shared | Instruction-based | 31 |
| **Commands** | 79 | Shared | Instruction-based | 31 |
| **Skills** | 181 | Shared | 10 (native format) | 37 |
| **Hook Events** | 8 types | 15 types | None yet | 11 types |
| **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks |
+2 -15
View File
@@ -78,17 +78,6 @@
---
## 最新动态
### v1.10.0 — 表面同步、运营工作流与 ECC 2.0 Alpha2026年4月)
- **公共表面已与真实仓库同步** —— 元数据、目录数量、插件清单以及安装文档现在都与实际开源表面保持一致。
- **运营与外向型工作流扩展** —— `brand-voice``social-graph-ranker``customer-billing-ops``google-workspace-ops` 等运营型 skill 已纳入同一系统。
- **媒体与发布工具补齐** —— `manim-video``remotion-video-creation` 以及社媒发布能力让技术讲解和发布流程直接在同一仓库内完成。
- **框架与产品表面继续扩展** —— `nestjs-patterns`、更完整的 Codex/OpenCode 安装表面,以及跨 harness 打包改进,让仓库不再局限于 Claude Code。
- **ECC 2.0 alpha 已进入仓库** —— `ecc2/` 下的 Rust 控制层现已可在本地构建,并提供 `dashboard``start``sessions``status``stop``resume``daemon` 命令。
- **生态加固持续推进** —— AgentShield、ECC Tools 成本控制、计费门户工作与网站刷新仍围绕核心插件持续交付。
## 快速开始
在 2 分钟内快速上手:
@@ -162,7 +151,7 @@ npx ecc-install typescript
/plugin list ecc@ecc
```
**完成!** 你现在可以使用 47 个代理、181 个技能和 80 个命令。
**完成!** 你现在可以使用 47 个代理、181 个技能和 79 个命令。
### multi-* 命令需要额外配置
@@ -626,9 +615,7 @@ cp everything-claude-code/commands/*.md ~/.claude/commands/
```
#### 将钩子配置添加到 settings.json
仅适用于手动安装:如果你没有通过 Claude 插件方式安装 ECC,可以`hooks/hooks.json` 中的钩子配置复制到你的 `~/.claude/settings.json` 文件中。
如果你是通过 `/plugin install` 安装 ECC,请不要再把这些钩子复制到 `settings.json`。Claude Code v2.1+ 会自动加载插件中的 `hooks/hooks.json`,重复注册会导致重复执行以及 `${CLAUDE_PLUGIN_ROOT}` 无法解析。
`hooks/hooks.json` 中的钩子配置复制到你的 `~/.claude/settings.json` 文件中。
#### 配置 MCP 服务
`mcp-configs/mcp-servers.json` 中复制需要的 MCP 服务定义,粘贴到官方 Claude Code 配置文件 `~/.claude/settings.json` 中;
-1
View File
@@ -146,7 +146,6 @@ skills:
commands:
- agent-sort
- aside
- auto-update
- build-fix
- checkpoint
- claw
-28
View File
@@ -1,28 +0,0 @@
---
description: Pull the latest ECC repo changes and reinstall the current managed targets.
disable-model-invocation: true
---
# Auto Update
Update ECC from its upstream repo and regenerate the current context's managed install using the original install-state request.
## Usage
```bash
# Preview the update without mutating anything
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();console.log(r)")}"
node "$ECC_ROOT/scripts/auto-update.js" --dry-run
# Update only Cursor-managed files in the current project
node "$ECC_ROOT/scripts/auto-update.js" --target cursor
# Override the ECC repo root explicitly
node "$ECC_ROOT/scripts/auto-update.js" --repo-root /path/to/everything-claude-code
```
## Notes
- This command uses the recorded install-state request and reruns `install-apply.js` after pulling the latest repo changes.
- Reinstall is intentional: it handles upstream renames and deletions that `repair.js` cannot safely reconstruct from stale operations alone.
- Use `--dry-run` first if you want to see the reconstructed reinstall plan before mutating anything.
+10 -16
View File
@@ -29,9 +29,8 @@ Use `/sessions info` when you need operator-surface context for a swarm: branch,
**Script:**
```bash
node -e "
const _r = (()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();
const sm = require(_r + '/scripts/lib/session-manager');
const aa = require(_r + '/scripts/lib/session-aliases');
const sm = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-manager');
const aa = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
const path = require('path');
const result = sm.getAllSessions({ limit: 20 });
@@ -71,9 +70,8 @@ Load and display a session's content (by ID or alias).
**Script:**
```bash
node -e "
const _r = (()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();
const sm = require(_r + '/scripts/lib/session-manager');
const aa = require(_r + '/scripts/lib/session-aliases');
const sm = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-manager');
const aa = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
const id = process.argv[1];
// First try to resolve as alias
@@ -145,9 +143,8 @@ Create a memorable alias for a session.
**Script:**
```bash
node -e "
const _r = (()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();
const sm = require(_r + '/scripts/lib/session-manager');
const aa = require(_r + '/scripts/lib/session-aliases');
const sm = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-manager');
const aa = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
const sessionId = process.argv[1];
const aliasName = process.argv[2];
@@ -186,8 +183,7 @@ Delete an existing alias.
**Script:**
```bash
node -e "
const _r = (()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();
const aa = require(_r + '/scripts/lib/session-aliases');
const aa = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
const aliasName = process.argv[1];
if (!aliasName) {
@@ -216,9 +212,8 @@ Show detailed information about a session.
**Script:**
```bash
node -e "
const _r = (()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();
const sm = require(_r + '/scripts/lib/session-manager');
const aa = require(_r + '/scripts/lib/session-aliases');
const sm = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-manager');
const aa = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
const id = process.argv[1];
const resolved = aa.resolveAlias(id);
@@ -267,8 +262,7 @@ Show all session aliases.
**Script:**
```bash
node -e "
const _r = (()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();
const aa = require(_r + '/scripts/lib/session-aliases');
const aa = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
const aliases = aa.listAliases();
console.log('Session Aliases (' + aliases.length + '):');
+3 -3
View File
@@ -13,21 +13,21 @@ Shows a comprehensive health dashboard for all skills in the portfolio with succ
Run the skill health CLI in dashboard mode:
```bash
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();console.log(r)")}"
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(!f.existsSync(p.join(d,q))){try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q))){d=c;break}}}catch(x){}}console.log(d)")}"
node "$ECC_ROOT/scripts/skills-health.js" --dashboard
```
For a specific panel only:
```bash
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();console.log(r)")}"
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(!f.existsSync(p.join(d,q))){try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q))){d=c;break}}}catch(x){}}console.log(d)")}"
node "$ECC_ROOT/scripts/skills-health.js" --dashboard --panel failures
```
For machine-readable output:
```bash
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();console.log(r)")}"
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(!f.existsSync(p.join(d,q))){try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q))){d=c;break}}}catch(x){}}console.log(d)")}"
node "$ECC_ROOT/scripts/skills-health.js" --dashboard --json
```
-15
View File
@@ -183,21 +183,6 @@ It is mostly:
- clarifying public docs
- continuing the ECC 2.0 operator/control-plane buildout
ECC 2.0 now ships a bounded migration audit entrypoint:
- `ecc migrate audit --source ~/.hermes`
- `ecc migrate plan --source ~/.hermes --output migration-plan.md`
- `ecc migrate scaffold --source ~/.hermes --output-dir migration-artifacts`
- `ecc migrate import-skills --source ~/.hermes --output-dir migration-artifacts/skills`
- `ecc migrate import-tools --source ~/.hermes --output-dir migration-artifacts/tools`
- `ecc migrate import-plugins --source ~/.hermes --output-dir migration-artifacts/plugins`
- `ecc migrate import-schedules --source ~/.hermes --dry-run`
- `ecc migrate import-remote --source ~/.hermes --dry-run`
- `ecc migrate import-env --source ~/.hermes --dry-run`
- `ecc migrate import-memory --source ~/.hermes`
Use that first to inventory the legacy workspace and map detected surfaces onto the current ECC2 scheduler, remote dispatch, memory graph, templates, and manual-translation lanes.
## What Still Belongs In Backlog
The remaining large migration themes are already tracked:
-2
View File
@@ -82,8 +82,6 @@ These stay local and should be configured per operator:
## Suggested Bring-Up Order
0. Run `ecc migrate audit --source ~/.hermes` first to inventory the legacy workspace and see which parts already map onto ECC2.
0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, scaffold reusable legacy skills with `ecc migrate import-skills --output-dir migration-artifacts/skills`, scaffold legacy tool translation templates with `ecc migrate import-tools --output-dir migration-artifacts/tools`, scaffold legacy bridge plugins with `ecc migrate import-plugins --output-dir migration-artifacts/plugins`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, preview safe env/service context with `ecc migrate import-env --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`.
1. Install ECC and verify the baseline harness setup.
2. Install Hermes and point it at ECC-imported skills.
3. Register the MCP servers you actually use every day.
+1 -3
View File
@@ -497,9 +497,7 @@ cp -r everything-claude-code/skills/* ~/.claude/skills/
#### settings.json にフックを追加
手動インストール時のみ、`hooks/hooks.json` のフックを `~/.claude/settings.json` にコピーします。
`/plugin install` で ECC を導入した場合は、これらのフックを `settings.json` にコピーしないでください。Claude Code v2.1+ はプラグインの `hooks/hooks.json` を自動読み込みするため、二重登録すると重複実行や `${CLAUDE_PLUGIN_ROOT}` の解決失敗が発生します。
`hooks/hooks.json` のフックを `~/.claude/settings.json` にコピーします。
#### MCP を設定
-9
View File
@@ -80,15 +80,6 @@ Este repositório contém apenas o código. Os guias explicam tudo.
## O Que Há de Novo
### v1.10.0 — Sincronização de Superfície, Fluxos Operacionais e ECC 2.0 Alpha (Abr 2026)
- **Superfície pública sincronizada com o repositório real** — metadados, contagens de catálogo, manifests de plugin e documentação de instalação agora refletem a superfície OSS que realmente é entregue.
- **Expansão dos fluxos operacionais e externos** — `brand-voice`, `social-graph-ranker`, `customer-billing-ops`, `google-workspace-ops` e skills relacionadas fortalecem a trilha operacional dentro do mesmo sistema.
- **Ferramentas de mídia e lançamento** — `manim-video`, `remotion-video-creation` e os fluxos de publicação social colocam explicadores técnicos e lançamento no mesmo repositório.
- **Crescimento de framework e superfície de produto** — `nestjs-patterns`, superfícies de instalação mais ricas para Codex/OpenCode e melhorias de empacotamento cross-harness ampliam o uso além do Claude Code.
- **ECC 2.0 alpha já está no repositório** — o plano de controle em Rust dentro de `ecc2/` já compila localmente e expõe `dashboard`, `start`, `sessions`, `status`, `stop`, `resume` e `daemon`.
- **Fortalecimento do ecossistema** — AgentShield, controles de custo do ECC Tools, trabalho no portal de billing e a renovação do site continuam sendo entregues ao redor do plugin principal.
### v1.9.0 — Instalação Seletiva e Expansão de Idiomas (Mar 2026)
- **Arquitetura de instalação seletiva** — Pipeline de instalação baseado em manifesto com `install-plan.js` e `install-apply.js` para instalação de componentes direcionada. O state store rastreia o que está instalado e habilita atualizações incrementais.
-9
View File
@@ -79,15 +79,6 @@ Bu repository yalnızca ham kodu içerir. Rehberler her şeyi açıklıyor.
## Yenilikler
### v1.10.0 — Surface Sync, Operatör İş Akışları ve ECC 2.0 Alpha (Nis 2026)
- **Public surface canlı repo ile senkronlandı** — metadata, katalog sayıları, plugin manifest'leri ve kurulum odaklı dokümanlar artık gerçek OSS yüzeyiyle eşleşiyor.
- **Operatör ve dışa dönük iş akışları büyüdü** — `brand-voice`, `social-graph-ranker`, `customer-billing-ops`, `google-workspace-ops` ve ilgili operatör skill'leri aynı sistem içinde tamamlandı.
- **Medya ve lansman araçları** — `manim-video`, `remotion-video-creation` ve sosyal yayın yüzeyleri teknik anlatım ve duyuru akışlarını aynı repo içine taşıdı.
- **Framework ve ürün yüzeyi genişledi** — `nestjs-patterns`, daha zengin Codex/OpenCode kurulum yüzeyleri ve çapraz harness paketleme iyileştirmeleri repo'yu Claude Code dışına da taşıdı.
- **ECC 2.0 alpha repoda** — `ecc2/` altındaki Rust kontrol katmanı artık yerelde derleniyor ve `dashboard`, `start`, `sessions`, `status`, `stop`, `resume` ve `daemon` komutlarını sunuyor.
- **Ekosistem sağlamlaştırma** — AgentShield, ECC Tools maliyet kontrolleri, billing portal işleri ve web yüzeyi çekirdek plugin etrafında birlikte gelişmeye devam ediyor.
### v1.9.0 — Seçici Kurulum & Dil Genişlemesi (Mar 2026)
- **Seçici kurulum mimarisi** — `install-plan.js` ve `install-apply.js` ile manifest-tabanlı kurulum pipeline'ı, hedefli component kurulumu için. State store neyin kurulu olduğunu takip eder ve artımlı güncellemelere olanak sağlar.
+2 -2
View File
@@ -1,6 +1,6 @@
# Everything Claude Code (ECC) — 智能体指令
这是一个**生产就绪的 AI 编码插件**,提供 47 个专业代理、181 项技能、80 条命令以及自动化钩子工作流,用于软件开发。
这是一个**生产就绪的 AI 编码插件**,提供 47 个专业代理、181 项技能、79 条命令以及自动化钩子工作流,用于软件开发。
**版本:** 1.10.0
@@ -148,7 +148,7 @@
```
agents/ — 47 个专业子代理
skills/ — 181 个工作流技能和领域知识
commands/ — 80 个斜杠命令
commands/ — 79 个斜杠命令
hooks/ — 基于触发的自动化
rules/ — 始终遵循的指导方针(通用 + 每种语言)
scripts/ — 跨平台 Node.js 实用工具
+3 -3
View File
@@ -209,7 +209,7 @@ npx ecc-install typescript
/plugin list ecc@ecc
```
**搞定!** 你现在可以使用 47 个智能体、181 项技能和 80 个命令了。
**搞定!** 你现在可以使用 47 个智能体、181 项技能和 79 个命令了。
***
@@ -1095,7 +1095,7 @@ opencode
| 功能特性 | Claude Code | OpenCode | 状态 |
|---------|-------------|----------|--------|
| 智能体 | PASS: 47 个 | PASS: 12 个 | **Claude Code 领先** |
| 命令 | PASS: 80 个 | PASS: 31 个 | **Claude Code 领先** |
| 命令 | PASS: 79 个 | PASS: 31 个 | **Claude Code 领先** |
| 技能 | PASS: 181 项 | PASS: 37 项 | **Claude Code 领先** |
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
@@ -1207,7 +1207,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
| 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|---------|------------|------------|-----------|----------|
| **智能体** | 47 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
| **命令** | 80 | 共享 | 基于指令 | 31 |
| **命令** | 79 | 共享 | 基于指令 | 31 |
| **技能** | 181 | 共享 | 10 (原生格式) | 37 |
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |
+1 -3
View File
@@ -318,9 +318,7 @@ cp -r everything-claude-code/skills/* ~/.claude/skills/
#### 將鉤子新增到 settings.json
僅在手動安裝時,才`hooks/hooks.json` 中的鉤子複製到您的 `~/.claude/settings.json`
如果您是透過 `/plugin install` 安裝 ECC,請不要再把這些鉤子複製到 `settings.json`。Claude Code v2.1+ 會自動載入外掛中的 `hooks/hooks.json`,重複註冊會導致重複執行以及 `${CLAUDE_PLUGIN_ROOT}` 無法解析。
`hooks/hooks.json` 中的鉤子複製到您的 `~/.claude/settings.json`
#### 設定 MCP
-168
View File
@@ -2,12 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
@@ -306,26 +300,6 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "cron"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07"
dependencies = [
"chrono",
"nom",
"once_cell",
]
[[package]]
name = "crossterm"
version = "0.28.1"
@@ -518,23 +492,19 @@ dependencies = [
"anyhow",
"chrono",
"clap",
"cron",
"crossterm 0.28.1",
"dirs",
"git2",
"libc",
"ratatui",
"regex",
"rusqlite",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
"tokio",
"toml",
"tracing",
"tracing-subscriber",
"ureq",
"uuid",
]
@@ -620,16 +590,6 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1179,16 +1139,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.1.1"
@@ -1660,20 +1610,6 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
@@ -1723,41 +1659,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -1891,12 +1792,6 @@ dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "siphasher"
version = "1.0.2"
@@ -1958,12 +1853,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "1.0.109"
@@ -2317,30 +2206,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -2507,24 +2372,6 @@ dependencies = [
"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]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "wezterm-bidi"
version = "0.2.3"
@@ -2678,15 +2525,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -2936,12 +2774,6 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"
-4
View File
@@ -25,9 +25,6 @@ git2 = "0.20"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
regex = "1"
sha2 = "0.10"
ureq = { version = "2", features = ["json"] }
# CLI
clap = { version = "4", features = ["derive"] }
@@ -43,7 +40,6 @@ libc = "0.2"
# Time
chrono = { version = "0.4", features = ["serde"] }
cron = "0.12"
# UUID for session IDs
uuid = { version = "1", features = ["v4"] }
+2 -72
View File
@@ -1,41 +1,13 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::fmt;
use crate::session::store::StateStore;
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum TaskPriority {
Low,
#[default]
Normal,
High,
Critical,
}
impl fmt::Display for TaskPriority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let label = match self {
Self::Low => "low",
Self::Normal => "normal",
Self::High => "high",
Self::Critical => "critical",
};
write!(f, "{label}")
}
}
/// Message types for inter-agent communication.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MessageType {
/// Task handoff from one agent to another
TaskHandoff {
task: String,
context: String,
#[serde(default)]
priority: TaskPriority,
},
TaskHandoff { task: String, context: String },
/// Agent requesting information from another
Query { question: String },
/// Response to a query
@@ -74,16 +46,7 @@ pub fn parse(content: &str) -> Option<MessageType> {
pub fn preview(msg_type: &str, content: &str) -> String {
match parse(content) {
Some(MessageType::TaskHandoff { task, .. }) => {
let priority = handoff_priority(content);
if priority == TaskPriority::Normal {
format!("handoff {}", truncate(&task, 56))
} else {
format!(
"handoff [{}] {}",
priority_label(priority),
truncate(&task, 48)
)
}
format!("handoff {}", truncate(&task, 56))
}
Some(MessageType::Query { question }) => {
format!("query {}", truncate(&question, 56))
@@ -112,39 +75,6 @@ pub fn preview(msg_type: &str, content: &str) -> String {
}
}
pub fn handoff_priority(content: &str) -> TaskPriority {
match parse(content) {
Some(MessageType::TaskHandoff { priority, .. }) => priority,
_ => extract_legacy_handoff_priority(content),
}
}
fn extract_legacy_handoff_priority(content: &str) -> TaskPriority {
let value: serde_json::Value = match serde_json::from_str(content) {
Ok(value) => value,
Err(_) => return TaskPriority::Normal,
};
match value
.get("priority")
.and_then(|priority| priority.as_str())
.unwrap_or("normal")
{
"low" => TaskPriority::Low,
"high" => TaskPriority::High,
"critical" => TaskPriority::Critical,
_ => TaskPriority::Normal,
}
}
fn priority_label(priority: TaskPriority) -> &'static str {
match priority {
TaskPriority::Low => "low",
TaskPriority::Normal => "normal",
TaskPriority::High => "high",
TaskPriority::Critical => "critical",
}
}
fn truncate(value: &str, max_chars: usize) -> String {
let trimmed = value.trim();
if trimmed.chars().count() <= max_chars {
+10 -1611
View File
File diff suppressed because it is too large Load Diff
+61 -9882
View File
File diff suppressed because it is too large Load Diff
-635
View File
@@ -1,635 +0,0 @@
use anyhow::Result;
use chrono::{DateTime, Local, Timelike};
use serde::{Deserialize, Serialize};
use serde_json::json;
#[cfg(not(test))]
use anyhow::Context;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotificationEvent {
SessionStarted,
SessionCompleted,
SessionFailed,
BudgetAlert,
ApprovalRequest,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct QuietHoursConfig {
pub enabled: bool,
pub start_hour: u8,
pub end_hour: u8,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct DesktopNotificationConfig {
pub enabled: bool,
pub session_started: bool,
pub session_completed: bool,
pub session_failed: bool,
pub budget_alerts: bool,
pub approval_requests: bool,
pub quiet_hours: QuietHoursConfig,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompletionSummaryDelivery {
#[default]
Desktop,
TuiPopup,
DesktopAndTuiPopup,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct CompletionSummaryConfig {
pub enabled: bool,
pub delivery: CompletionSummaryDelivery,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WebhookProvider {
#[default]
Slack,
Discord,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct WebhookTarget {
pub provider: WebhookProvider,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct WebhookNotificationConfig {
pub enabled: bool,
pub session_started: bool,
pub session_completed: bool,
pub session_failed: bool,
pub budget_alerts: bool,
pub approval_requests: bool,
pub targets: Vec<WebhookTarget>,
}
#[derive(Debug, Clone)]
pub struct DesktopNotifier {
config: DesktopNotificationConfig,
}
#[derive(Debug, Clone)]
pub struct WebhookNotifier {
config: WebhookNotificationConfig,
}
impl Default for QuietHoursConfig {
fn default() -> Self {
Self {
enabled: false,
start_hour: 22,
end_hour: 8,
}
}
}
impl QuietHoursConfig {
pub fn sanitized(self) -> Self {
let valid = self.start_hour <= 23 && self.end_hour <= 23;
if valid {
self
} else {
Self::default()
}
}
pub fn is_active(&self, now: DateTime<Local>) -> bool {
if !self.enabled {
return false;
}
let quiet = self.clone().sanitized();
if quiet.start_hour == quiet.end_hour {
return false;
}
let hour = now.hour() as u8;
if quiet.start_hour < quiet.end_hour {
hour >= quiet.start_hour && hour < quiet.end_hour
} else {
hour >= quiet.start_hour || hour < quiet.end_hour
}
}
}
impl Default for DesktopNotificationConfig {
fn default() -> Self {
Self {
enabled: true,
session_started: false,
session_completed: true,
session_failed: true,
budget_alerts: true,
approval_requests: true,
quiet_hours: QuietHoursConfig::default(),
}
}
}
impl DesktopNotificationConfig {
pub fn sanitized(self) -> Self {
Self {
quiet_hours: self.quiet_hours.sanitized(),
..self
}
}
pub fn allows(&self, event: NotificationEvent, now: DateTime<Local>) -> bool {
let config = self.clone().sanitized();
if !config.enabled || config.quiet_hours.is_active(now) {
return false;
}
match event {
NotificationEvent::SessionStarted => config.session_started,
NotificationEvent::SessionCompleted => config.session_completed,
NotificationEvent::SessionFailed => config.session_failed,
NotificationEvent::BudgetAlert => config.budget_alerts,
NotificationEvent::ApprovalRequest => config.approval_requests,
}
}
}
impl Default for CompletionSummaryConfig {
fn default() -> Self {
Self {
enabled: true,
delivery: CompletionSummaryDelivery::Desktop,
}
}
}
impl CompletionSummaryConfig {
pub fn desktop_enabled(&self) -> bool {
self.enabled
&& matches!(
self.delivery,
CompletionSummaryDelivery::Desktop | CompletionSummaryDelivery::DesktopAndTuiPopup
)
}
pub fn popup_enabled(&self) -> bool {
self.enabled
&& matches!(
self.delivery,
CompletionSummaryDelivery::TuiPopup | CompletionSummaryDelivery::DesktopAndTuiPopup
)
}
}
impl Default for WebhookTarget {
fn default() -> Self {
Self {
provider: WebhookProvider::Slack,
url: String::new(),
}
}
}
impl WebhookTarget {
fn sanitized(self) -> Option<Self> {
let url = self.url.trim().to_string();
if url.starts_with("https://") || url.starts_with("http://") {
Some(Self { url, ..self })
} else {
None
}
}
}
impl Default for WebhookNotificationConfig {
fn default() -> Self {
Self {
enabled: false,
session_started: true,
session_completed: true,
session_failed: true,
budget_alerts: true,
approval_requests: false,
targets: Vec::new(),
}
}
}
impl WebhookNotificationConfig {
pub fn sanitized(self) -> Self {
Self {
targets: self
.targets
.into_iter()
.filter_map(WebhookTarget::sanitized)
.collect(),
..self
}
}
pub fn allows(&self, event: NotificationEvent) -> bool {
let config = self.clone().sanitized();
if !config.enabled || config.targets.is_empty() {
return false;
}
match event {
NotificationEvent::SessionStarted => config.session_started,
NotificationEvent::SessionCompleted => config.session_completed,
NotificationEvent::SessionFailed => config.session_failed,
NotificationEvent::BudgetAlert => config.budget_alerts,
NotificationEvent::ApprovalRequest => config.approval_requests,
}
}
}
impl DesktopNotifier {
pub fn new(config: DesktopNotificationConfig) -> Self {
Self {
config: config.sanitized(),
}
}
pub fn notify(&self, event: NotificationEvent, title: &str, body: &str) -> bool {
match self.try_notify(event, title, body, Local::now()) {
Ok(sent) => sent,
Err(error) => {
tracing::warn!("Failed to send desktop notification: {error}");
false
}
}
}
fn try_notify(
&self,
event: NotificationEvent,
title: &str,
body: &str,
now: DateTime<Local>,
) -> Result<bool> {
if !self.config.allows(event, now) {
return Ok(false);
}
let Some((program, args)) = notification_command(std::env::consts::OS, title, body) else {
return Ok(false);
};
run_notification_command(&program, &args)?;
Ok(true)
}
}
impl WebhookNotifier {
pub fn new(config: WebhookNotificationConfig) -> Self {
Self {
config: config.sanitized(),
}
}
pub fn notify(&self, event: NotificationEvent, message: &str) -> bool {
match self.try_notify(event, message) {
Ok(sent) => sent,
Err(error) => {
tracing::warn!("Failed to send webhook notification: {error}");
false
}
}
}
fn try_notify(&self, event: NotificationEvent, message: &str) -> Result<bool> {
self.try_notify_with(event, message, send_webhook_request)
}
fn try_notify_with<F>(
&self,
event: NotificationEvent,
message: &str,
mut sender: F,
) -> Result<bool>
where
F: FnMut(&WebhookTarget, serde_json::Value) -> Result<()>,
{
if !self.config.allows(event) {
return Ok(false);
}
let mut delivered = false;
for target in &self.config.targets {
let payload = webhook_payload(target, message);
match sender(target, payload) {
Ok(()) => delivered = true,
Err(error) => tracing::warn!(
"Failed to deliver {:?} webhook notification to {}: {error}",
target.provider,
target.url
),
}
}
Ok(delivered)
}
}
fn notification_command(platform: &str, title: &str, body: &str) -> Option<(String, Vec<String>)> {
match platform {
"macos" => Some((
"osascript".to_string(),
vec![
"-e".to_string(),
format!(
"display notification \"{}\" with title \"{}\"",
sanitize_osascript(body),
sanitize_osascript(title)
),
],
)),
"linux" => Some((
"notify-send".to_string(),
vec![
"--app-name".to_string(),
"ECC 2.0".to_string(),
title.trim().to_string(),
body.trim().to_string(),
],
)),
_ => None,
}
}
fn webhook_payload(target: &WebhookTarget, message: &str) -> serde_json::Value {
match target.provider {
WebhookProvider::Slack => json!({
"text": message,
}),
WebhookProvider::Discord => json!({
"content": message,
"allowed_mentions": {
"parse": []
}
}),
}
}
#[cfg(not(test))]
fn run_notification_command(program: &str, args: &[String]) -> Result<()> {
let status = std::process::Command::new(program)
.args(args)
.status()
.with_context(|| format!("launch {program}"))?;
if status.success() {
Ok(())
} else {
anyhow::bail!("{program} exited with {status}");
}
}
#[cfg(test)]
fn run_notification_command(_program: &str, _args: &[String]) -> Result<()> {
Ok(())
}
#[cfg(not(test))]
fn send_webhook_request(target: &WebhookTarget, payload: serde_json::Value) -> Result<()> {
let agent = ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(5))
.timeout_read(std::time::Duration::from_secs(5))
.build();
let response = agent
.post(&target.url)
.send_json(payload)
.with_context(|| format!("POST {}", target.url))?;
if response.status() >= 200 && response.status() < 300 {
Ok(())
} else {
anyhow::bail!("{} returned {}", target.url, response.status());
}
}
#[cfg(test)]
fn send_webhook_request(_target: &WebhookTarget, _payload: serde_json::Value) -> Result<()> {
Ok(())
}
fn sanitize_osascript(value: &str) -> String {
value
.replace('\\', "")
.replace('"', "\u{201C}")
.replace('\n', " ")
}
#[cfg(test)]
mod tests {
use super::{
notification_command, webhook_payload, CompletionSummaryDelivery,
DesktopNotificationConfig, DesktopNotifier, NotificationEvent, QuietHoursConfig,
WebhookNotificationConfig, WebhookNotifier, WebhookProvider, WebhookTarget,
};
use chrono::{Local, TimeZone};
use serde_json::json;
#[test]
fn quiet_hours_support_cross_midnight_ranges() {
let quiet_hours = QuietHoursConfig {
enabled: true,
start_hour: 22,
end_hour: 8,
};
assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 23, 0, 0).unwrap()));
assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 7, 0, 0).unwrap()));
assert!(!quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 14, 0, 0).unwrap()));
}
#[test]
fn quiet_hours_support_same_day_ranges() {
let quiet_hours = QuietHoursConfig {
enabled: true,
start_hour: 9,
end_hour: 17,
};
assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 10, 0, 0).unwrap()));
assert!(!quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 18, 0, 0).unwrap()));
}
#[test]
fn notification_preferences_respect_event_flags() {
let mut config = DesktopNotificationConfig::default();
config.session_completed = false;
let now = Local.with_ymd_and_hms(2026, 4, 9, 12, 0, 0).unwrap();
assert!(!config.allows(NotificationEvent::SessionCompleted, now));
assert!(config.allows(NotificationEvent::BudgetAlert, now));
assert!(!config.allows(NotificationEvent::SessionStarted, now));
}
#[test]
fn notifier_skips_delivery_during_quiet_hours() {
let mut config = DesktopNotificationConfig::default();
config.quiet_hours = QuietHoursConfig {
enabled: true,
start_hour: 22,
end_hour: 8,
};
let notifier = DesktopNotifier::new(config);
assert!(!notifier
.try_notify(
NotificationEvent::ApprovalRequest,
"ECC 2.0: Approval needed",
"worker-123 needs review",
Local.with_ymd_and_hms(2026, 4, 9, 23, 0, 0).unwrap(),
)
.unwrap());
}
#[test]
fn macos_notifications_use_osascript() {
let (program, args) =
notification_command("macos", "ECC 2.0: Completed", "Task finished").unwrap();
assert_eq!(program, "osascript");
assert_eq!(args[0], "-e");
assert!(args[1].contains("display notification"));
assert!(args[1].contains("ECC 2.0: Completed"));
}
#[test]
fn linux_notifications_use_notify_send() {
let (program, args) =
notification_command("linux", "ECC 2.0: Approval needed", "worker-123").unwrap();
assert_eq!(program, "notify-send");
assert_eq!(args[0], "--app-name");
assert_eq!(args[1], "ECC 2.0");
assert_eq!(args[2], "ECC 2.0: Approval needed");
assert_eq!(args[3], "worker-123");
}
#[test]
fn webhook_notifications_require_enabled_targets_and_event() {
let mut config = WebhookNotificationConfig::default();
assert!(!config.allows(NotificationEvent::SessionCompleted));
config.enabled = true;
config.targets = vec![WebhookTarget {
provider: WebhookProvider::Slack,
url: "https://hooks.slack.test/services/abc".to_string(),
}];
assert!(config.allows(NotificationEvent::SessionCompleted));
assert!(config.allows(NotificationEvent::SessionStarted));
assert!(!config.allows(NotificationEvent::ApprovalRequest));
}
#[test]
fn webhook_sanitization_filters_invalid_urls() {
let config = WebhookNotificationConfig {
enabled: true,
targets: vec![
WebhookTarget {
provider: WebhookProvider::Slack,
url: "https://hooks.slack.test/services/abc".to_string(),
},
WebhookTarget {
provider: WebhookProvider::Discord,
url: "ftp://discord.invalid".to_string(),
},
],
..WebhookNotificationConfig::default()
}
.sanitized();
assert_eq!(config.targets.len(), 1);
assert_eq!(config.targets[0].provider, WebhookProvider::Slack);
}
#[test]
fn slack_webhook_payload_uses_text() {
let payload = webhook_payload(
&WebhookTarget {
provider: WebhookProvider::Slack,
url: "https://hooks.slack.test/services/abc".to_string(),
},
"*ECC 2.0* hello",
);
assert_eq!(payload, json!({ "text": "*ECC 2.0* hello" }));
}
#[test]
fn discord_webhook_payload_disables_mentions() {
let payload = webhook_payload(
&WebhookTarget {
provider: WebhookProvider::Discord,
url: "https://discord.test/api/webhooks/123".to_string(),
},
"```text\nsummary\n```",
);
assert_eq!(
payload,
json!({
"content": "```text\nsummary\n```",
"allowed_mentions": { "parse": [] }
})
);
}
#[test]
fn webhook_notifier_sends_to_each_target() {
let notifier = WebhookNotifier::new(WebhookNotificationConfig {
enabled: true,
targets: vec![
WebhookTarget {
provider: WebhookProvider::Slack,
url: "https://hooks.slack.test/services/abc".to_string(),
},
WebhookTarget {
provider: WebhookProvider::Discord,
url: "https://discord.test/api/webhooks/123".to_string(),
},
],
..WebhookNotificationConfig::default()
});
let mut sent = Vec::new();
let delivered = notifier
.try_notify_with(
NotificationEvent::SessionCompleted,
"payload text",
|target, payload| {
sent.push((target.provider, payload));
Ok(())
},
)
.unwrap();
assert!(delivered);
assert_eq!(sent.len(), 2);
assert_eq!(sent[0].0, WebhookProvider::Slack);
assert_eq!(sent[1].0, WebhookProvider::Discord);
}
#[test]
fn completion_summary_delivery_defaults_to_desktop() {
assert_eq!(
CompletionSummaryDelivery::default(),
CompletionSummaryDelivery::Desktop
);
}
}
-13
View File
@@ -9,9 +9,7 @@ pub struct ToolCallEvent {
pub session_id: String,
pub tool_name: String,
pub input_summary: String,
pub input_params_json: String,
pub output_summary: String,
pub trigger_summary: String,
pub duration_ms: u64,
pub risk_score: f64,
}
@@ -49,9 +47,7 @@ impl ToolCallEvent {
.score,
tool_name,
input_summary,
input_params_json: "{}".to_string(),
output_summary: output_summary.into(),
trigger_summary: String::new(),
duration_ms,
}
}
@@ -242,9 +238,7 @@ pub struct ToolLogEntry {
pub session_id: String,
pub tool_name: String,
pub input_summary: String,
pub input_params_json: String,
pub output_summary: String,
pub trigger_summary: String,
pub duration_ms: u64,
pub risk_score: f64,
pub timestamp: String,
@@ -274,9 +268,7 @@ impl<'a> ToolLogger<'a> {
&event.session_id,
&event.tool_name,
&event.input_summary,
&event.input_params_json,
&event.output_summary,
&event.trigger_summary,
event.duration_ms,
event.risk_score,
&timestamp,
@@ -314,8 +306,6 @@ mod tests {
Session {
id: id.to_string(),
task: "test task".to_string(),
project: "workspace".to_string(),
task_group: "general".to_string(),
agent_type: "claude".to_string(),
working_dir: PathBuf::from("/tmp"),
state: SessionState::Pending,
@@ -323,7 +313,6 @@ mod tests {
worktree: None,
created_at: now,
updated_at: now,
last_heartbeat_at: now,
metrics: SessionMetrics::default(),
}
}
@@ -408,8 +397,6 @@ mod tests {
assert_eq!(first_page.entries.len(), 2);
assert_eq!(first_page.entries[0].tool_name, "Bash");
assert_eq!(first_page.entries[1].tool_name, "Write");
assert_eq!(first_page.entries[0].input_params_json, "{}");
assert_eq!(first_page.entries[0].trigger_summary, "");
let second_page = logger.query("sess-1", 2, 2)?;
assert_eq!(second_page.total, 3);
+25 -62
View File
@@ -22,19 +22,13 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
resume_crashed_sessions(&db)?;
let heartbeat_interval = Duration::from_secs(cfg.heartbeat_interval_secs);
let timeout = Duration::from_secs(cfg.session_timeout_secs);
loop {
if let Err(e) = check_sessions(&db, &cfg) {
if let Err(e) = check_sessions(&db, timeout) {
tracing::error!("Session check failed: {e}");
}
if let Err(e) = maybe_run_due_schedules(&db, &cfg).await {
tracing::error!("Scheduled task dispatch pass failed: {e}");
}
if let Err(e) = maybe_run_remote_dispatch(&db, &cfg).await {
tracing::error!("Remote dispatch pass failed: {e}");
}
if let Err(e) = coordinate_backlog_cycle(&db, &cfg).await {
tracing::error!("Backlog coordination pass failed: {e}");
}
@@ -43,14 +37,10 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
tracing::error!("Worktree auto-merge pass failed: {e}");
}
if let Err(e) = maybe_auto_prune_inactive_worktrees(&db, &cfg).await {
if let Err(e) = maybe_auto_prune_inactive_worktrees(&db).await {
tracing::error!("Worktree auto-prune pass failed: {e}");
}
if let Err(e) = manager::activate_pending_worktree_sessions(&db, &cfg).await {
tracing::error!("Queued worktree activation pass failed: {e}");
}
time::sleep(heartbeat_interval).await;
}
}
@@ -92,38 +82,28 @@ where
Ok(failed_sessions)
}
fn check_sessions(db: &StateStore, cfg: &Config) -> Result<()> {
let _ = manager::enforce_session_heartbeats(db, cfg)?;
fn check_sessions(db: &StateStore, timeout: Duration) -> Result<()> {
let sessions = db.list_sessions()?;
for session in sessions {
if session.state != SessionState::Running {
continue;
}
let elapsed = chrono::Utc::now()
.signed_duration_since(session.updated_at)
.to_std()
.unwrap_or(Duration::ZERO);
if elapsed > timeout {
tracing::warn!("Session {} timed out after {:?}", session.id, elapsed);
db.update_state_and_pid(&session.id, &SessionState::Failed, None)?;
}
}
Ok(())
}
async fn maybe_run_due_schedules(db: &StateStore, cfg: &Config) -> Result<usize> {
let outcomes = manager::run_due_schedules(db, cfg, cfg.max_parallel_sessions).await?;
if !outcomes.is_empty() {
tracing::info!("Dispatched {} scheduled task(s)", outcomes.len());
}
Ok(outcomes.len())
}
async fn maybe_run_remote_dispatch(db: &StateStore, cfg: &Config) -> Result<usize> {
let outcomes =
manager::run_remote_dispatch_requests(db, cfg, cfg.max_parallel_sessions).await?;
let routed = outcomes
.iter()
.filter(|outcome| {
matches!(
outcome.action,
manager::RemoteDispatchAction::SpawnedTopLevel
| manager::RemoteDispatchAction::Assigned(_)
)
})
.count();
if routed > 0 {
tracing::info!("Dispatched {} remote request(s)", routed);
}
Ok(routed)
}
async fn maybe_auto_dispatch(db: &StateStore, cfg: &Config) -> Result<usize> {
let summary = maybe_auto_dispatch_with_recorder(
cfg,
@@ -428,9 +408,9 @@ where
Ok(merged)
}
async fn maybe_auto_prune_inactive_worktrees(db: &StateStore, cfg: &Config) -> Result<usize> {
async fn maybe_auto_prune_inactive_worktrees(db: &StateStore) -> Result<usize> {
maybe_auto_prune_inactive_worktrees_with_recorder(
|| manager::prune_inactive_worktrees(db, cfg),
|| manager::prune_inactive_worktrees(db),
|pruned, active| db.record_daemon_auto_prune_pass(pruned, active),
)
.await
@@ -456,7 +436,6 @@ where
let outcome = prune().await?;
let pruned = outcome.cleaned_session_ids.len();
let active = outcome.active_with_worktree_ids.len();
let retained = outcome.retained_session_ids.len();
record(pruned, active)?;
if pruned > 0 {
@@ -465,9 +444,6 @@ where
if active > 0 {
tracing::info!("Skipped {active} active worktree(s) during auto-prune");
}
if retained > 0 {
tracing::info!("Deferred {retained} inactive worktree(s) within retention");
}
Ok(pruned)
}
@@ -515,8 +491,6 @@ mod tests {
Session {
id: id.to_string(),
task: "Recover crashed worker".to_string(),
project: "workspace".to_string(),
task_group: "general".to_string(),
agent_type: "claude".to_string(),
working_dir: PathBuf::from("/tmp"),
state,
@@ -524,7 +498,6 @@ mod tests {
worktree: None,
created_at: now,
updated_at: now,
last_heartbeat_at: now,
metrics: SessionMetrics::default(),
}
}
@@ -1237,11 +1210,9 @@ mod tests {
invoked_flag.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(manager::WorktreeBulkMergeOutcome {
merged: Vec::new(),
rebased: Vec::new(),
active_with_worktree_ids: Vec::new(),
conflicted_session_ids: Vec::new(),
dirty_worktree_ids: Vec::new(),
blocked_by_queue_session_ids: Vec::new(),
failures: Vec::new(),
})
}
@@ -1276,16 +1247,9 @@ mod tests {
cleaned_worktree: true,
},
],
rebased: vec![manager::WorktreeRebaseOutcome {
session_id: "worker-r".to_string(),
branch: "ecc/worker-r".to_string(),
base_branch: "main".to_string(),
already_up_to_date: false,
}],
active_with_worktree_ids: vec!["worker-c".to_string()],
conflicted_session_ids: vec!["worker-d".to_string()],
dirty_worktree_ids: vec!["worker-e".to_string()],
blocked_by_queue_session_ids: vec!["worker-f".to_string()],
failures: Vec::new(),
})
})
@@ -1305,7 +1269,6 @@ mod tests {
Ok(manager::WorktreePruneOutcome {
cleaned_session_ids: vec!["stopped-a".to_string(), "stopped-b".to_string()],
active_with_worktree_ids: vec!["running-a".to_string()],
retained_session_ids: vec!["retained-a".to_string()],
})
},
move |pruned, active| {
File diff suppressed because it is too large Load Diff
+21 -849
View File
@@ -6,307 +6,13 @@ pub mod store;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
use std::path::Path;
use std::path::PathBuf;
pub type SessionAgentProfile = crate::config::ResolvedAgentProfile;
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum HarnessKind {
#[default]
Unknown,
Claude,
Codex,
OpenCode,
Gemini,
Cursor,
Kiro,
Trae,
Zed,
FactoryDroid,
Windsurf,
}
impl HarnessKind {
pub fn from_agent_type(agent_type: &str) -> Self {
match agent_type.trim().to_ascii_lowercase().as_str() {
"claude" | "claude-code" => Self::Claude,
"codex" => Self::Codex,
"opencode" => Self::OpenCode,
"gemini" | "gemini-cli" => Self::Gemini,
"cursor" => Self::Cursor,
"kiro" => Self::Kiro,
"trae" => Self::Trae,
"zed" => Self::Zed,
"factory-droid" | "factory_droid" | "factorydroid" => Self::FactoryDroid,
"windsurf" => Self::Windsurf,
_ => Self::Unknown,
}
}
pub fn from_db_value(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"claude" => Self::Claude,
"codex" => Self::Codex,
"opencode" => Self::OpenCode,
"gemini" => Self::Gemini,
"cursor" => Self::Cursor,
"kiro" => Self::Kiro,
"trae" => Self::Trae,
"zed" => Self::Zed,
"factory_droid" => Self::FactoryDroid,
"windsurf" => Self::Windsurf,
_ => Self::Unknown,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::Claude => "claude",
Self::Codex => "codex",
Self::OpenCode => "opencode",
Self::Gemini => "gemini",
Self::Cursor => "cursor",
Self::Kiro => "kiro",
Self::Trae => "trae",
Self::Zed => "zed",
Self::FactoryDroid => "factory_droid",
Self::Windsurf => "windsurf",
}
}
pub fn canonical_agent_type(agent_type: &str) -> String {
match Self::from_agent_type(agent_type) {
Self::Unknown => agent_type.trim().to_ascii_lowercase(),
harness => harness.as_str().to_string(),
}
}
fn supports_direct_execution(self) -> bool {
matches!(
self,
Self::Claude | Self::Codex | Self::OpenCode | Self::Gemini
)
}
fn project_markers(self) -> &'static [&'static str] {
match self {
Self::Claude => &[".claude"],
Self::Codex => &[".codex", ".codex-plugin"],
Self::OpenCode => &[".opencode"],
Self::Gemini => &[".gemini"],
Self::Cursor => &[".cursor"],
Self::Kiro => &[".kiro"],
Self::Trae => &[".trae"],
Self::Zed => &[".zed"],
Self::FactoryDroid => &[".factory-droid", ".factory_droid"],
Self::Windsurf => &[".windsurf"],
Self::Unknown => &[],
}
}
}
impl fmt::Display for HarnessKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionHarnessInfo {
pub primary: HarnessKind,
pub primary_label: String,
pub detected: Vec<HarnessKind>,
pub detected_labels: Vec<String>,
}
impl SessionHarnessInfo {
fn detected_labels_for(detected: &[HarnessKind]) -> Vec<String> {
detected.iter().map(|harness| harness.to_string()).collect()
}
fn configured_detected_labels(cfg: &crate::config::Config, working_dir: &Path) -> Vec<String> {
let mut labels = Vec::new();
for (name, runner) in &cfg.harness_runners {
if runner.project_markers.is_empty() {
continue;
}
if runner
.project_markers
.iter()
.any(|marker| working_dir.join(marker).exists())
{
let label = Self::runner_key(name);
if !label.is_empty() && !labels.contains(&label) {
labels.push(label);
}
}
}
labels
}
pub fn runner_key(agent_type: &str) -> String {
let canonical = HarnessKind::canonical_agent_type(agent_type);
match HarnessKind::from_agent_type(&canonical) {
HarnessKind::Unknown if canonical.is_empty() => {
HarnessKind::Unknown.as_str().to_string()
}
HarnessKind::Unknown => canonical,
harness => harness.as_str().to_string(),
}
}
fn primary_label_for(agent_type: &str, primary: HarnessKind) -> String {
match primary {
HarnessKind::Unknown => {
let label = Self::runner_key(agent_type);
if label.is_empty() {
HarnessKind::Unknown.as_str().to_string()
} else {
label
}
}
harness => harness.as_str().to_string(),
}
}
pub fn detect(agent_type: &str, working_dir: &Path) -> Self {
let runner_key = Self::runner_key(agent_type);
let detected = [
HarnessKind::Claude,
HarnessKind::Codex,
HarnessKind::OpenCode,
HarnessKind::Gemini,
HarnessKind::Cursor,
HarnessKind::Kiro,
HarnessKind::Trae,
HarnessKind::Zed,
HarnessKind::FactoryDroid,
HarnessKind::Windsurf,
]
.into_iter()
.filter(|harness| {
harness
.project_markers()
.iter()
.any(|marker| working_dir.join(marker).exists())
})
.collect::<Vec<_>>();
let primary = match HarnessKind::from_agent_type(&runner_key) {
HarnessKind::Unknown if runner_key == HarnessKind::Unknown.as_str() => {
detected.first().copied().unwrap_or(HarnessKind::Unknown)
}
HarnessKind::Unknown => HarnessKind::Unknown,
harness => harness,
};
let detected_labels = Self::detected_labels_for(&detected);
Self {
primary,
primary_label: Self::primary_label_for(agent_type, primary),
detected,
detected_labels,
}
}
pub fn from_persisted(
harness_label: &str,
agent_type: &str,
working_dir: &Path,
detected: Vec<HarnessKind>,
) -> Self {
let primary = HarnessKind::from_db_value(harness_label);
if primary == HarnessKind::Unknown && detected.is_empty() && harness_label.trim().is_empty()
{
return Self::detect(agent_type, working_dir);
}
let normalized_label = harness_label.trim().to_ascii_lowercase();
let detected_labels = Self::detected_labels_for(&detected);
Self {
primary,
primary_label: if normalized_label.is_empty() {
Self::primary_label_for(agent_type, primary)
} else {
normalized_label
},
detected,
detected_labels,
}
}
pub fn with_config_detection(
mut self,
cfg: &crate::config::Config,
working_dir: &Path,
) -> Self {
for label in Self::configured_detected_labels(cfg, working_dir) {
if !self.detected_labels.contains(&label) {
self.detected_labels.push(label);
}
}
if self.primary == HarnessKind::Unknown
&& self.primary_label == HarnessKind::Unknown.as_str()
&& !self.detected_labels.is_empty()
{
self.primary_label = self.detected_labels[0].clone();
}
self
}
pub fn resolve_requested_agent_type(
cfg: &crate::config::Config,
requested_agent_type: &str,
working_dir: &Path,
) -> String {
let canonical = HarnessKind::canonical_agent_type(requested_agent_type);
if !canonical.is_empty() && canonical != "auto" {
return canonical;
}
let detected = Self::detect("", working_dir).with_config_detection(cfg, working_dir);
if detected.primary_label != HarnessKind::Unknown.as_str()
&& Self::can_launch_detected_label(cfg, &detected.primary_label)
{
return Self::runner_key(&detected.primary_label);
}
for label in &detected.detected_labels {
if Self::can_launch_detected_label(cfg, label) {
return Self::runner_key(label);
}
}
HarnessKind::Claude.as_str().to_string()
}
fn can_launch_detected_label(cfg: &crate::config::Config, label: &str) -> bool {
cfg.harness_runner(label).is_some()
|| HarnessKind::from_agent_type(label).supports_direct_execution()
}
pub fn detected_summary(&self) -> String {
if self.detected_labels.is_empty() {
"none detected".to_string()
} else {
self.detected_labels.join(", ")
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub task: String,
pub project: String,
pub task_group: String,
pub agent_type: String,
pub working_dir: PathBuf,
pub state: SessionState,
@@ -314,7 +20,6 @@ pub struct Session {
pub worktree: Option<WorktreeInfo>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_heartbeat_at: DateTime<Utc>,
pub metrics: SessionMetrics,
}
@@ -323,7 +28,6 @@ pub enum SessionState {
Pending,
Running,
Idle,
Stale,
Completed,
Failed,
Stopped,
@@ -335,7 +39,6 @@ impl fmt::Display for SessionState {
SessionState::Pending => write!(f, "pending"),
SessionState::Running => write!(f, "running"),
SessionState::Idle => write!(f, "idle"),
SessionState::Stale => write!(f, "stale"),
SessionState::Completed => write!(f, "completed"),
SessionState::Failed => write!(f, "failed"),
SessionState::Stopped => write!(f, "stopped"),
@@ -357,21 +60,12 @@ impl SessionState {
) | (
SessionState::Running,
SessionState::Idle
| SessionState::Stale
| SessionState::Completed
| SessionState::Failed
| SessionState::Stopped
) | (
SessionState::Idle,
SessionState::Running
| SessionState::Stale
| SessionState::Completed
| SessionState::Failed
| SessionState::Stopped
) | (
SessionState::Stale,
SessionState::Running
| SessionState::Idle
| SessionState::Completed
| SessionState::Failed
| SessionState::Stopped
@@ -384,7 +78,6 @@ impl SessionState {
match value {
"running" => SessionState::Running,
"idle" => SessionState::Idle,
"stale" => SessionState::Stale,
"completed" => SessionState::Completed,
"failed" => SessionState::Failed,
"stopped" => SessionState::Stopped,
@@ -402,8 +95,6 @@ pub struct WorktreeInfo {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionMetrics {
pub input_tokens: u64,
pub output_tokens: u64,
pub tokens_used: u64,
pub tool_calls: u64,
pub files_changed: u32,
@@ -411,6 +102,27 @@ pub struct SessionMetrics {
pub cost_usd: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionBoardMeta {
pub lane: String,
pub project: Option<String>,
pub feature: Option<String>,
pub issue: Option<String>,
pub row_label: Option<String>,
pub previous_lane: Option<String>,
pub previous_row_label: Option<String>,
pub column_index: i64,
pub row_index: i64,
pub stack_index: i64,
pub progress_percent: i64,
pub status_detail: Option<String>,
pub movement_note: Option<String>,
pub activity_kind: Option<String>,
pub activity_note: Option<String>,
pub handoff_backlog: i64,
pub conflict_signal: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMessage {
pub id: i64,
@@ -421,543 +133,3 @@ pub struct SessionMessage {
pub read: bool,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScheduledTask {
pub id: i64,
pub cron_expr: String,
pub task: String,
pub agent_type: String,
pub profile_name: Option<String>,
pub working_dir: PathBuf,
pub project: String,
pub task_group: String,
pub use_worktree: bool,
pub last_run_at: Option<DateTime<Utc>>,
pub next_run_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RemoteDispatchRequest {
pub id: i64,
pub request_kind: RemoteDispatchKind,
pub target_session_id: Option<String>,
pub task: String,
pub target_url: Option<String>,
pub priority: crate::comms::TaskPriority,
pub agent_type: String,
pub profile_name: Option<String>,
pub working_dir: PathBuf,
pub project: String,
pub task_group: String,
pub use_worktree: bool,
pub source: String,
pub requester: Option<String>,
pub status: RemoteDispatchStatus,
pub result_session_id: Option<String>,
pub result_action: Option<String>,
pub error: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub dispatched_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RemoteDispatchKind {
Standard,
ComputerUse,
}
impl fmt::Display for RemoteDispatchKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Standard => write!(f, "standard"),
Self::ComputerUse => write!(f, "computer_use"),
}
}
}
impl RemoteDispatchKind {
pub fn from_db_value(value: &str) -> Self {
match value {
"computer_use" => Self::ComputerUse,
_ => Self::Standard,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RemoteDispatchStatus {
Pending,
Dispatched,
Failed,
}
impl fmt::Display for RemoteDispatchStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Pending => write!(f, "pending"),
Self::Dispatched => write!(f, "dispatched"),
Self::Failed => write!(f, "failed"),
}
}
}
impl RemoteDispatchStatus {
pub fn from_db_value(value: &str) -> Self {
match value {
"dispatched" => Self::Dispatched,
"failed" => Self::Failed,
_ => Self::Pending,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileActivityEntry {
pub session_id: String,
pub action: FileActivityAction,
pub path: String,
pub summary: String,
pub diff_preview: Option<String>,
pub patch_preview: Option<String>,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DecisionLogEntry {
pub id: i64,
pub session_id: String,
pub decision: String,
pub alternatives: Vec<String>,
pub reasoning: String,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphEntity {
pub id: i64,
pub session_id: Option<String>,
pub entity_type: String,
pub name: String,
pub path: Option<String>,
pub summary: String,
pub metadata: BTreeMap<String, String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphRelation {
pub id: i64,
pub session_id: Option<String>,
pub from_entity_id: i64,
pub from_entity_type: String,
pub from_entity_name: String,
pub to_entity_id: i64,
pub to_entity_type: String,
pub to_entity_name: String,
pub relation_type: String,
pub summary: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphEntityDetail {
pub entity: ContextGraphEntity,
pub outgoing: Vec<ContextGraphRelation>,
pub incoming: Vec<ContextGraphRelation>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphObservation {
pub id: i64,
pub session_id: Option<String>,
pub entity_id: i64,
pub entity_type: String,
pub entity_name: String,
pub observation_type: String,
pub priority: ContextObservationPriority,
pub pinned: bool,
pub summary: String,
pub details: BTreeMap<String, String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphRecallEntry {
pub entity: ContextGraphEntity,
pub score: u64,
pub matched_terms: Vec<String>,
pub relation_count: usize,
pub observation_count: usize,
pub max_observation_priority: ContextObservationPriority,
pub has_pinned_observation: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum ContextObservationPriority {
Low,
Normal,
High,
Critical,
}
impl Default for ContextObservationPriority {
fn default() -> Self {
Self::Normal
}
}
impl fmt::Display for ContextObservationPriority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Low => write!(f, "low"),
Self::Normal => write!(f, "normal"),
Self::High => write!(f, "high"),
Self::Critical => write!(f, "critical"),
}
}
}
impl ContextObservationPriority {
pub fn from_db_value(value: i64) -> Self {
match value {
0 => Self::Low,
2 => Self::High,
3 => Self::Critical,
_ => Self::Normal,
}
}
pub fn as_db_value(self) -> i64 {
match self {
Self::Low => 0,
Self::Normal => 1,
Self::High => 2,
Self::Critical => 3,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphSyncStats {
pub sessions_scanned: usize,
pub decisions_processed: usize,
pub file_events_processed: usize,
pub messages_processed: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphCompactionStats {
pub entities_scanned: usize,
pub duplicate_observations_deleted: usize,
pub overflow_observations_deleted: usize,
pub observations_retained: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FileActivityAction {
Read,
Create,
Modify,
Move,
Delete,
Touch,
}
pub fn normalize_group_label(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn default_project_label(working_dir: &Path) -> String {
working_dir
.file_name()
.and_then(|value| value.to_str())
.and_then(normalize_group_label)
.unwrap_or_else(|| "workspace".to_string())
}
pub fn default_task_group_label(task: &str) -> String {
normalize_group_label(task).unwrap_or_else(|| "general".to_string())
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionGrouping {
pub project: Option<String>,
pub task_group: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
struct TestDir {
path: PathBuf,
}
impl TestDir {
fn new(label: &str) -> Result<Self, Box<dyn std::error::Error>> {
let path =
std::env::temp_dir().join(format!("ecc2-{}-{}", label, uuid::Uuid::new_v4()));
fs::create_dir_all(&path)?;
Ok(Self { path })
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn detect_session_harness_prefers_agent_type_and_collects_project_markers(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-detect")?;
fs::create_dir_all(repo.path().join(".codex"))?;
fs::create_dir_all(repo.path().join(".claude"))?;
let harness = SessionHarnessInfo::detect("claude", repo.path());
assert_eq!(harness.primary, HarnessKind::Claude);
assert_eq!(harness.primary_label, "claude");
assert_eq!(
harness.detected,
vec![HarnessKind::Claude, HarnessKind::Codex]
);
assert_eq!(harness.detected_labels, vec!["claude", "codex"]);
assert_eq!(harness.detected_summary(), "claude, codex");
Ok(())
}
#[test]
fn detect_session_harness_falls_back_to_project_markers_when_agent_unspecified(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-markers")?;
fs::create_dir_all(repo.path().join(".gemini"))?;
let harness = SessionHarnessInfo::detect("", repo.path());
assert_eq!(harness.primary, HarnessKind::Gemini);
assert_eq!(harness.primary_label, "gemini");
assert_eq!(harness.detected, vec![HarnessKind::Gemini]);
assert_eq!(harness.detected_labels, vec!["gemini"]);
Ok(())
}
#[test]
fn detect_session_harness_collects_extended_builtin_markers(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-extended-markers")?;
fs::create_dir_all(repo.path().join(".zed"))?;
fs::create_dir_all(repo.path().join(".factory-droid"))?;
fs::create_dir_all(repo.path().join(".windsurf"))?;
let harness = SessionHarnessInfo::detect("", repo.path());
assert_eq!(harness.primary, HarnessKind::Zed);
assert_eq!(harness.primary_label, "zed");
assert_eq!(
harness.detected,
vec![
HarnessKind::Zed,
HarnessKind::FactoryDroid,
HarnessKind::Windsurf
]
);
assert_eq!(
harness.detected_labels,
vec!["zed", "factory_droid", "windsurf"]
);
Ok(())
}
#[test]
fn canonical_agent_type_normalizes_known_aliases() {
assert_eq!(HarnessKind::canonical_agent_type("claude-code"), "claude");
assert_eq!(HarnessKind::canonical_agent_type("gemini-cli"), "gemini");
assert_eq!(
HarnessKind::canonical_agent_type("factory-droid"),
"factory_droid"
);
assert_eq!(
HarnessKind::canonical_agent_type(" custom-runner "),
"custom-runner"
);
}
#[test]
fn detect_session_harness_preserves_custom_agent_label_without_markers() {
let harness = SessionHarnessInfo::detect(" custom-runner ", Path::new("."));
assert_eq!(harness.primary, HarnessKind::Unknown);
assert_eq!(harness.primary_label, "custom-runner");
assert!(harness.detected.is_empty());
assert!(harness.detected_labels.is_empty());
}
#[test]
fn detect_session_harness_preserves_custom_agent_label_with_project_markers(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-custom-markers")?;
fs::create_dir_all(repo.path().join(".claude"))?;
fs::create_dir_all(repo.path().join(".codex"))?;
let harness = SessionHarnessInfo::detect("custom-runner", repo.path());
assert_eq!(harness.primary, HarnessKind::Unknown);
assert_eq!(harness.primary_label, "custom-runner");
assert_eq!(
harness.detected,
vec![HarnessKind::Claude, HarnessKind::Codex]
);
assert_eq!(harness.detected_labels, vec!["claude", "codex"]);
Ok(())
}
#[test]
fn config_detection_adds_custom_markers_to_detected_summary(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-custom-config")?;
fs::create_dir_all(repo.path().join(".acme"))?;
let mut cfg = crate::config::Config::default();
cfg.harness_runners.insert(
"acme-runner".to_string(),
crate::config::HarnessRunnerConfig {
project_markers: vec![PathBuf::from(".acme")],
..Default::default()
},
);
let harness =
SessionHarnessInfo::detect("", repo.path()).with_config_detection(&cfg, repo.path());
assert_eq!(harness.primary, HarnessKind::Unknown);
assert_eq!(harness.primary_label, "acme-runner");
assert_eq!(harness.detected_labels, vec!["acme-runner"]);
assert_eq!(harness.detected_summary(), "acme-runner");
Ok(())
}
#[test]
fn config_detection_preserves_custom_primary_label_and_appends_marker_matches(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-config-append")?;
fs::create_dir_all(repo.path().join(".acme"))?;
fs::create_dir_all(repo.path().join(".codex"))?;
let mut cfg = crate::config::Config::default();
cfg.harness_runners.insert(
"acme-runner".to_string(),
crate::config::HarnessRunnerConfig {
project_markers: vec![PathBuf::from(".acme")],
..Default::default()
},
);
let harness = SessionHarnessInfo::detect("acme-runner", repo.path())
.with_config_detection(&cfg, repo.path());
assert_eq!(harness.primary, HarnessKind::Unknown);
assert_eq!(harness.primary_label, "acme-runner");
assert_eq!(harness.detected_labels, vec!["codex", "acme-runner"]);
assert_eq!(harness.detected_summary(), "codex, acme-runner");
Ok(())
}
#[test]
fn runner_key_uses_canonical_label_for_unknown_harnesses() {
assert_eq!(
SessionHarnessInfo::runner_key(" custom-runner "),
"custom-runner"
);
assert_eq!(SessionHarnessInfo::runner_key("claude-code"), "claude");
}
#[test]
fn resolve_requested_agent_type_uses_detected_builtin_marker_for_auto(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-resolve-auto-built-in")?;
fs::create_dir_all(repo.path().join(".codex"))?;
let resolved = SessionHarnessInfo::resolve_requested_agent_type(
&crate::config::Config::default(),
"auto",
repo.path(),
);
assert_eq!(resolved, "codex");
Ok(())
}
#[test]
fn resolve_requested_agent_type_uses_configured_marker_for_auto(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-resolve-auto-custom")?;
fs::create_dir_all(repo.path().join(".acme"))?;
let mut cfg = crate::config::Config::default();
cfg.harness_runners.insert(
"acme-runner".to_string(),
crate::config::HarnessRunnerConfig {
project_markers: vec![PathBuf::from(".acme")],
..Default::default()
},
);
let resolved = SessionHarnessInfo::resolve_requested_agent_type(&cfg, "auto", repo.path());
assert_eq!(resolved, "acme-runner");
Ok(())
}
#[test]
fn resolve_requested_agent_type_skips_nonlaunchable_builtin_markers_without_runner(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-resolve-auto-nonlaunchable")?;
fs::create_dir_all(repo.path().join(".zed"))?;
let resolved = SessionHarnessInfo::resolve_requested_agent_type(
&crate::config::Config::default(),
"auto",
repo.path(),
);
assert_eq!(resolved, "claude");
Ok(())
}
#[test]
fn resolve_requested_agent_type_uses_configured_runner_for_extended_builtin_markers(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-resolve-auto-extended-runner")?;
fs::create_dir_all(repo.path().join(".windsurf"))?;
let mut cfg = crate::config::Config::default();
cfg.harness_runners.insert(
"windsurf".to_string(),
crate::config::HarnessRunnerConfig {
program: "windsurf".to_string(),
..Default::default()
},
);
let resolved = SessionHarnessInfo::resolve_requested_agent_type(&cfg, "auto", repo.path());
assert_eq!(resolved, "windsurf");
Ok(())
}
#[test]
fn resolve_requested_agent_type_falls_back_to_claude_without_markers() {
let resolved = SessionHarnessInfo::resolve_requested_agent_type(
&crate::config::Config::default(),
"auto",
Path::new("."),
);
assert_eq!(resolved, "claude");
}
}
+4 -27
View File
@@ -32,31 +32,6 @@ impl OutputStream {
pub struct OutputLine {
pub stream: OutputStream,
pub text: String,
pub timestamp: String,
}
impl OutputLine {
pub fn new(
stream: OutputStream,
text: impl Into<String>,
timestamp: impl Into<String>,
) -> Self {
Self {
stream,
text: text.into(),
timestamp: timestamp.into(),
}
}
pub fn with_current_timestamp(stream: OutputStream, text: impl Into<String>) -> Self {
Self::new(stream, text, chrono::Utc::now().to_rfc3339())
}
pub fn occurred_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::parse_from_rfc3339(&self.timestamp)
.ok()
.map(|timestamp| timestamp.with_timezone(&chrono::Utc))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -95,7 +70,10 @@ impl SessionOutputStore {
}
pub fn push_line(&self, session_id: &str, stream: OutputStream, text: impl Into<String>) {
let line = OutputLine::with_current_timestamp(stream, text);
let line = OutputLine {
stream,
text: text.into(),
};
{
let mut buffers = self.lock_buffers();
@@ -167,6 +145,5 @@ mod tests {
assert_eq!(event.session_id, "session-1");
assert_eq!(event.line.stream, OutputStream::Stderr);
assert_eq!(event.line.text, "problem");
assert!(event.line.occurred_at().is_some());
}
}
+3 -93
View File
@@ -5,7 +5,6 @@ use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
use tokio::process::Command;
use tokio::sync::{mpsc, oneshot};
use tokio::time::{self, MissedTickBehavior};
use super::output::{OutputStream, SessionOutputStore};
use super::store::StateStore;
@@ -27,9 +26,6 @@ enum DbMessage {
line: String,
ack: oneshot::Sender<DbAck>,
},
TouchHeartbeat {
ack: oneshot::Sender<DbAck>,
},
}
#[derive(Clone)]
@@ -57,10 +53,6 @@ impl DbWriter {
.await
}
async fn touch_heartbeat(&self) -> Result<()> {
self.send(|ack| DbMessage::TouchHeartbeat { ack }).await
}
async fn send<F>(&self, build: F) -> Result<()>
where
F: FnOnce(oneshot::Sender<DbAck>) -> DbMessage,
@@ -119,17 +111,6 @@ fn run_db_writer(db_path: PathBuf, session_id: String, mut rx: mpsc::UnboundedRe
};
let _ = ack.send(result);
}
DbMessage::TouchHeartbeat { ack } => {
let result = match opened.as_ref() {
Some(db) => db
.touch_heartbeat(&session_id)
.map_err(|error| error.to_string()),
None => Err(open_error
.clone()
.unwrap_or_else(|| "Failed to open state store".to_string())),
};
let _ = ack.send(result);
}
}
}
}
@@ -139,7 +120,6 @@ pub async fn capture_command_output(
session_id: String,
mut command: Command,
output_store: SessionOutputStore,
heartbeat_interval: std::time::Duration,
) -> Result<ExitStatus> {
let db_writer = DbWriter::start(db_path, session_id.clone());
@@ -172,19 +152,6 @@ pub async fn capture_command_output(
.ok_or_else(|| anyhow::anyhow!("Spawned process did not expose a process id"))?;
db_writer.update_pid(Some(pid)).await?;
db_writer.update_state(SessionState::Running).await?;
db_writer.touch_heartbeat().await?;
let heartbeat_writer = db_writer.clone();
let heartbeat_task = tokio::spawn(async move {
let mut ticker = time::interval(heartbeat_interval);
ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
ticker.tick().await;
if heartbeat_writer.touch_heartbeat().await.is_err() {
break;
}
}
});
let stdout_task = tokio::spawn(capture_stream(
session_id.clone(),
@@ -202,8 +169,6 @@ pub async fn capture_command_output(
));
let status = child.wait().await?;
heartbeat_task.abort();
let _ = heartbeat_task.await;
stdout_task.await??;
stderr_task.await??;
@@ -272,8 +237,6 @@ mod tests {
db.insert_session(&Session {
id: session_id.clone(),
task: "stream output".to_string(),
project: "workspace".to_string(),
task_group: "general".to_string(),
agent_type: "test".to_string(),
working_dir: env::temp_dir(),
state: SessionState::Pending,
@@ -281,7 +244,6 @@ mod tests {
worktree: None,
created_at: now,
updated_at: now,
last_heartbeat_at: now,
metrics: SessionMetrics::default(),
})?;
@@ -292,14 +254,9 @@ mod tests {
.arg("-c")
.arg("printf 'alpha\\n'; printf 'beta\\n' >&2");
let status = capture_command_output(
db_path.clone(),
session_id.clone(),
command,
output_store,
std::time::Duration::from_millis(10),
)
.await?;
let status =
capture_command_output(db_path.clone(), session_id.clone(), command, output_store)
.await?;
assert!(status.success());
@@ -329,51 +286,4 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn capture_command_output_updates_heartbeat_for_quiet_processes() -> Result<()> {
let db_path = env::temp_dir().join(format!("ecc2-runtime-heartbeat-{}.db", Uuid::new_v4()));
let db = StateStore::open(&db_path)?;
let session_id = "session-heartbeat".to_string();
let now = Utc::now();
db.insert_session(&Session {
id: session_id.clone(),
task: "quiet process".to_string(),
project: "workspace".to_string(),
task_group: "general".to_string(),
agent_type: "test".to_string(),
working_dir: env::temp_dir(),
state: SessionState::Pending,
pid: None,
worktree: None,
created_at: now,
updated_at: now,
last_heartbeat_at: now,
metrics: SessionMetrics::default(),
})?;
let mut command = Command::new("/bin/sh");
command.arg("-c").arg("sleep 0.05");
let _ = capture_command_output(
db_path.clone(),
session_id.clone(),
command,
SessionOutputStore::default(),
std::time::Duration::from_millis(10),
)
.await?;
let db = StateStore::open(&db_path)?;
let session = db
.get_session(&session_id)?
.expect("session should still exist");
assert!(session.last_heartbeat_at > now);
assert_eq!(session.state, SessionState::Completed);
let _ = std::fs::remove_file(db_path);
Ok(())
}
}
+885 -4831
View File
File diff suppressed because it is too large Load Diff
-76
View File
@@ -27,49 +27,9 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
if event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = event::read()? {
if dashboard.has_active_completion_popup() {
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
(_, KeyCode::Esc) | (_, KeyCode::Enter) | (_, KeyCode::Char(' ')) => {
dashboard.dismiss_completion_popup();
}
_ => {}
}
continue;
}
if dashboard.is_input_mode() {
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
(_, KeyCode::Esc) => dashboard.cancel_input(),
(_, KeyCode::Enter) => dashboard.submit_input().await,
(_, KeyCode::Backspace) => dashboard.pop_input_char(),
(modifiers, KeyCode::Char(ch))
if !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
dashboard.push_input_char(ch);
}
_ => {}
}
continue;
}
if dashboard.is_pane_command_mode() {
if dashboard.handle_pane_command_key(key) {
continue;
}
}
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
(KeyModifiers::CONTROL, KeyCode::Char('w')) => {
dashboard.begin_pane_command_mode()
}
(_, KeyCode::Char('q')) => break,
_ if dashboard.handle_pane_navigation_key(key) => {}
(_, KeyCode::Tab) => dashboard.next_pane(),
(KeyModifiers::SHIFT, KeyCode::BackTab) => dashboard.prev_pane(),
(_, KeyCode::Char('+')) | (_, KeyCode::Char('=')) => {
@@ -78,53 +38,17 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
(_, KeyCode::Char('-')) => dashboard.decrease_pane_size(),
(_, KeyCode::Char('j')) | (_, KeyCode::Down) => dashboard.scroll_down(),
(_, KeyCode::Char('k')) | (_, KeyCode::Up) => dashboard.scroll_up(),
(_, KeyCode::Char('[')) => dashboard.focus_previous_delegate(),
(_, KeyCode::Char(']')) => dashboard.focus_next_delegate(),
(_, KeyCode::Enter) => dashboard.open_focused_delegate(),
(_, KeyCode::Char('/')) => dashboard.begin_search(),
(_, KeyCode::Esc) => dashboard.clear_search(),
(_, KeyCode::Char('n')) if dashboard.has_active_search() => {
dashboard.next_search_match()
}
(_, KeyCode::Char('N')) if dashboard.has_active_search() => {
dashboard.prev_search_match()
}
(_, KeyCode::Char('N')) => dashboard.begin_spawn_prompt(),
(_, KeyCode::Char('n')) => dashboard.new_session().await,
(_, KeyCode::Char('a')) => dashboard.assign_selected().await,
(_, KeyCode::Char('b')) => dashboard.rebalance_selected_team().await,
(_, KeyCode::Char('B')) => dashboard.rebalance_all_teams().await,
(_, KeyCode::Char('i')) => dashboard.drain_inbox_selected().await,
(_, KeyCode::Char('I')) => dashboard.focus_next_approval_target(),
(_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await,
(_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await,
(_, KeyCode::Char('K')) => dashboard.toggle_context_graph_mode(),
(_, KeyCode::Char('h')) => dashboard.collapse_selected_pane(),
(_, KeyCode::Char('H')) => dashboard.restore_collapsed_panes(),
(_, KeyCode::Char('y')) => dashboard.toggle_timeline_mode(),
(_, KeyCode::Char('E')) if dashboard.is_context_graph_mode() => {
dashboard.cycle_graph_entity_filter()
}
(_, KeyCode::Char('E')) => dashboard.cycle_timeline_event_filter(),
(_, KeyCode::Char('v')) => dashboard.toggle_output_mode(),
(_, KeyCode::Char('z')) => dashboard.toggle_git_status_mode(),
(_, KeyCode::Char('V')) => dashboard.toggle_diff_view_mode(),
(_, KeyCode::Char('S')) => dashboard.stage_selected_git_status(),
(_, KeyCode::Char('U')) => dashboard.unstage_selected_git_status(),
(_, KeyCode::Char('R')) => dashboard.reset_selected_git_status(),
(_, KeyCode::Char('C')) => dashboard.begin_commit_prompt(),
(_, KeyCode::Char('P')) => dashboard.begin_pr_prompt(),
(_, KeyCode::Char('{')) => dashboard.prev_diff_hunk(),
(_, KeyCode::Char('}')) => dashboard.next_diff_hunk(),
(_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(),
(_, KeyCode::Char('e')) => dashboard.toggle_output_filter(),
(_, KeyCode::Char('f')) => dashboard.cycle_output_time_filter(),
(_, KeyCode::Char('A')) => dashboard.toggle_search_scope(),
(_, KeyCode::Char('o')) => dashboard.toggle_search_agent_filter(),
(_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await,
(_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await,
(_, KeyCode::Char('l')) => dashboard.cycle_pane_layout(),
(_, KeyCode::Char('T')) => dashboard.toggle_theme(),
(_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(),
(_, KeyCode::Char('t')) => dashboard.toggle_auto_worktree_policy(),
(_, KeyCode::Char('w')) => dashboard.toggle_auto_merge_policy(),
+888 -10658
View File
File diff suppressed because it is too large Load Diff
+33 -134
View File
@@ -1,49 +1,30 @@
use crate::config::BudgetAlertThresholds;
use ratatui::{
prelude::*,
text::{Line, Span},
widgets::{Gauge, Paragraph, Widget},
};
pub(crate) const WARNING_THRESHOLD: f64 = 0.8;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum BudgetState {
Unconfigured,
Normal,
Alert50,
Alert75,
Alert90,
Warning,
OverBudget,
}
impl BudgetState {
fn badge(self, thresholds: BudgetAlertThresholds) -> Option<String> {
match self {
Self::Alert50 => Some(threshold_label(thresholds.advisory)),
Self::Alert75 => Some(threshold_label(thresholds.warning)),
Self::Alert90 => Some(threshold_label(thresholds.critical)),
Self::OverBudget => Some("over budget".to_string()),
Self::Unconfigured => Some("no budget".to_string()),
Self::Normal => None,
}
pub(crate) const fn is_warning(self) -> bool {
matches!(self, Self::Warning | Self::OverBudget)
}
pub(crate) fn summary_suffix(self, thresholds: BudgetAlertThresholds) -> Option<String> {
fn badge(self) -> Option<&'static str> {
match self {
Self::Alert50 => Some(format!(
"Budget alert {}",
threshold_label(thresholds.advisory)
)),
Self::Alert75 => Some(format!(
"Budget alert {}",
threshold_label(thresholds.warning)
)),
Self::Alert90 => Some(format!(
"Budget alert {}",
threshold_label(thresholds.critical)
)),
Self::OverBudget => Some("Budget exceeded".to_string()),
Self::Unconfigured | Self::Normal => None,
Self::Warning => Some("warning"),
Self::OverBudget => Some("over budget"),
Self::Unconfigured => Some("no budget"),
Self::Normal => None,
}
}
@@ -51,13 +32,11 @@ impl BudgetState {
let base = Style::default().fg(match self {
Self::Unconfigured => Color::DarkGray,
Self::Normal => Color::DarkGray,
Self::Alert50 => Color::Cyan,
Self::Alert75 => Color::Yellow,
Self::Alert90 => Color::LightRed,
Self::Warning => Color::Yellow,
Self::OverBudget => Color::Red,
});
if matches!(self, Self::Alert75 | Self::Alert90 | Self::OverBudget) {
if self.is_warning() {
base.add_modifier(Modifier::BOLD)
} else {
base
@@ -76,43 +55,30 @@ pub(crate) struct TokenMeter<'a> {
title: &'a str,
used: f64,
budget: f64,
thresholds: BudgetAlertThresholds,
format: MeterFormat,
}
impl<'a> TokenMeter<'a> {
pub(crate) fn tokens(
title: &'a str,
used: u64,
budget: u64,
thresholds: BudgetAlertThresholds,
) -> Self {
pub(crate) fn tokens(title: &'a str, used: u64, budget: u64) -> Self {
Self {
title,
used: used as f64,
budget: budget as f64,
thresholds,
format: MeterFormat::Tokens,
}
}
pub(crate) fn currency(
title: &'a str,
used: f64,
budget: f64,
thresholds: BudgetAlertThresholds,
) -> Self {
pub(crate) fn currency(title: &'a str, used: f64, budget: f64) -> Self {
Self {
title,
used,
budget,
thresholds,
format: MeterFormat::Currency,
}
}
pub(crate) fn state(&self) -> BudgetState {
budget_state(self.used, self.budget, self.thresholds)
budget_state(self.used, self.budget)
}
fn ratio(&self) -> f64 {
@@ -131,7 +97,7 @@ impl<'a> TokenMeter<'a> {
.add_modifier(Modifier::BOLD),
)];
if let Some(badge) = self.state().badge(self.thresholds) {
if let Some(badge) = self.state().badge() {
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("[{badge}]"), self.state().style()));
}
@@ -199,7 +165,7 @@ impl Widget for TokenMeter<'_> {
.label(self.display_label())
.gauge_style(
Style::default()
.fg(gradient_color(self.ratio(), self.thresholds))
.fg(gradient_color(self.ratio()))
.add_modifier(Modifier::BOLD),
)
.style(Style::default().fg(Color::DarkGray))
@@ -216,51 +182,35 @@ pub(crate) fn budget_ratio(used: f64, budget: f64) -> f64 {
}
}
pub(crate) fn budget_state(
used: f64,
budget: f64,
thresholds: BudgetAlertThresholds,
) -> BudgetState {
pub(crate) fn budget_state(used: f64, budget: f64) -> BudgetState {
if budget <= 0.0 {
BudgetState::Unconfigured
} else if used / budget >= 1.0 {
BudgetState::OverBudget
} else if used / budget >= thresholds.critical {
BudgetState::Alert90
} else if used / budget >= thresholds.warning {
BudgetState::Alert75
} else if used / budget >= thresholds.advisory {
BudgetState::Alert50
} else if used / budget >= WARNING_THRESHOLD {
BudgetState::Warning
} else {
BudgetState::Normal
}
}
pub(crate) fn gradient_color(ratio: f64, thresholds: BudgetAlertThresholds) -> Color {
pub(crate) fn gradient_color(ratio: f64) -> Color {
const GREEN: (u8, u8, u8) = (34, 197, 94);
const YELLOW: (u8, u8, u8) = (234, 179, 8);
const RED: (u8, u8, u8) = (239, 68, 68);
let clamped = ratio.clamp(0.0, 1.0);
if clamped <= thresholds.warning {
interpolate_rgb(
GREEN,
YELLOW,
clamped / thresholds.warning.max(f64::EPSILON),
)
if clamped <= WARNING_THRESHOLD {
interpolate_rgb(GREEN, YELLOW, clamped / WARNING_THRESHOLD)
} else {
interpolate_rgb(
YELLOW,
RED,
(clamped - thresholds.warning) / (1.0 - thresholds.warning),
(clamped - WARNING_THRESHOLD) / (1.0 - WARNING_THRESHOLD),
)
}
}
fn threshold_label(value: f64) -> String {
format!("{}%", (value * 100.0).round() as u64)
}
pub(crate) fn format_currency(value: f64) -> String {
format!("${value:.2}")
}
@@ -296,76 +246,25 @@ fn interpolate_rgb(from: (u8, u8, u8), to: (u8, u8, u8), ratio: f64) -> Color {
mod tests {
use ratatui::{buffer::Buffer, layout::Rect, style::Color, widgets::Widget};
use crate::config::{BudgetAlertThresholds, Config};
use super::{gradient_color, threshold_label, BudgetState, TokenMeter};
use super::{gradient_color, BudgetState, TokenMeter};
#[test]
fn budget_state_uses_alert_threshold_ladder() {
assert_eq!(
TokenMeter::tokens("Token Budget", 50, 100, Config::BUDGET_ALERT_THRESHOLDS).state(),
BudgetState::Alert50
);
assert_eq!(
TokenMeter::tokens("Token Budget", 75, 100, Config::BUDGET_ALERT_THRESHOLDS).state(),
BudgetState::Alert75
);
assert_eq!(
TokenMeter::tokens("Token Budget", 90, 100, Config::BUDGET_ALERT_THRESHOLDS).state(),
BudgetState::Alert90
);
assert_eq!(
TokenMeter::tokens("Token Budget", 100, 100, Config::BUDGET_ALERT_THRESHOLDS).state(),
BudgetState::OverBudget
);
fn warning_state_starts_at_eighty_percent() {
let meter = TokenMeter::tokens("Token Budget", 80, 100);
assert_eq!(meter.state(), BudgetState::Warning);
}
#[test]
fn gradient_runs_from_green_to_yellow_to_red() {
assert_eq!(
gradient_color(0.0, Config::BUDGET_ALERT_THRESHOLDS),
Color::Rgb(34, 197, 94)
);
assert_eq!(
gradient_color(0.75, Config::BUDGET_ALERT_THRESHOLDS),
Color::Rgb(234, 179, 8)
);
assert_eq!(
gradient_color(1.0, Config::BUDGET_ALERT_THRESHOLDS),
Color::Rgb(239, 68, 68)
);
}
#[test]
fn token_meter_uses_custom_budget_thresholds() {
let meter = TokenMeter::tokens(
"Token Budget",
45,
100,
BudgetAlertThresholds {
advisory: 0.40,
warning: 0.70,
critical: 0.85,
},
);
assert_eq!(meter.state(), BudgetState::Alert50);
}
#[test]
fn threshold_label_rounds_to_percent() {
assert_eq!(threshold_label(0.4), "40%");
assert_eq!(threshold_label(0.875), "88%");
assert_eq!(gradient_color(0.0), Color::Rgb(34, 197, 94));
assert_eq!(gradient_color(0.8), Color::Rgb(234, 179, 8));
assert_eq!(gradient_color(1.0), Color::Rgb(239, 68, 68));
}
#[test]
fn token_meter_renders_compact_usage_label() {
let meter = TokenMeter::tokens(
"Token Budget",
4_000,
10_000,
Config::BUDGET_ALERT_THRESHOLDS,
);
let meter = TokenMeter::tokens("Token Budget", 4_000, 10_000);
let area = Rect::new(0, 0, 48, 2);
let mut buffer = Buffer::empty(area);
+53 -1882
View File
File diff suppressed because it is too large Load Diff
+1 -13
View File
@@ -7,7 +7,7 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:bash:block-no-verify\" \"scripts/hooks/block-no-verify.js\" \"minimal,standard,strict\""
"command": "npx block-no-verify@1.1.2"
}
],
"description": "Block git hook-bypass flag to protect pre-commit, commit-msg, and pre-push hooks from being skipped",
@@ -260,18 +260,6 @@
"description": "Capture governance events from tool outputs. Enable with ECC_GOVERNANCE_CAPTURE=1",
"id": "post:governance-capture"
},
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:session-activity-tracker\" \"scripts/hooks/session-activity-tracker.js\" \"standard,strict\"",
"timeout": 10
}
],
"description": "Track per-session tool calls and file activity for ECC2 metrics",
"id": "post:session-activity-tracker"
},
{
"matcher": "*",
"hooks": [
-1
View File
@@ -90,7 +90,6 @@
".gemini",
".opencode",
"mcp-configs",
"scripts/auto-update.js",
"scripts/setup-package-manager.js"
],
"targets": [
+1 -5
View File
@@ -50,7 +50,6 @@
".opencode/index.ts",
".opencode/opencode.json",
".opencode/package.json",
".opencode/package-lock.json",
".opencode/tsconfig.json",
".opencode/MIGRATION.md",
".opencode/README.md",
@@ -91,17 +90,14 @@
"scripts/uninstall.js",
"skills/",
"AGENTS.md",
"agent.yaml",
".claude-plugin/plugin.json",
".claude-plugin/marketplace.json",
".claude-plugin/README.md",
".codex-plugin/plugin.json",
".codex-plugin/README.md",
".mcp.json",
"install.sh",
"install.ps1",
"llms.txt",
"VERSION"
"llms.txt"
],
"bin": {
"ecc": "scripts/ecc.js",
-361
View File
@@ -1,361 +0,0 @@
#!/usr/bin/env node
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const { discoverInstalledStates } = require('./lib/install-lifecycle');
const { SUPPORTED_INSTALL_TARGETS } = require('./lib/install-manifests');
function showHelp(exitCode = 0) {
console.log(`
Usage: node scripts/auto-update.js [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--repo-root <path>] [--dry-run] [--json]
Pull the latest ECC repo changes and reinstall the current context's managed targets
using the original install-state request.
`);
process.exit(exitCode);
}
function parseArgs(argv) {
const args = argv.slice(2);
const parsed = {
targets: [],
repoRoot: null,
dryRun: false,
json: false,
help: false,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--target') {
parsed.targets.push(args[index + 1] || null);
index += 1;
} else if (arg === '--repo-root') {
parsed.repoRoot = args[index + 1] || null;
index += 1;
} else if (arg === '--dry-run') {
parsed.dryRun = true;
} else if (arg === '--json') {
parsed.json = true;
} else if (arg === '--help' || arg === '-h') {
parsed.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return parsed;
}
function deriveRepoRootFromState(state) {
const operations = Array.isArray(state && state.operations) ? state.operations : [];
for (const operation of operations) {
if (typeof operation.sourcePath !== 'string' || !operation.sourcePath.trim()) {
continue;
}
if (typeof operation.sourceRelativePath !== 'string' || !operation.sourceRelativePath.trim()) {
continue;
}
const relativeParts = operation.sourceRelativePath
.split(/[\\/]+/)
.filter(Boolean);
if (relativeParts.length === 0) {
continue;
}
let repoRoot = path.resolve(operation.sourcePath);
for (let index = 0; index < relativeParts.length; index += 1) {
repoRoot = path.dirname(repoRoot);
}
return repoRoot;
}
throw new Error('Unable to infer ECC repo root from install-state operations');
}
function buildInstallApplyArgs(record) {
const state = record.state;
const target = state.target.target || record.adapter.target;
const request = state.request || {};
const args = [];
if (target) {
args.push('--target', target);
}
if (request.profile) {
args.push('--profile', request.profile);
}
if (Array.isArray(request.modules) && request.modules.length > 0) {
args.push('--modules', request.modules.join(','));
}
for (const componentId of Array.isArray(request.includeComponents) ? request.includeComponents : []) {
args.push('--with', componentId);
}
for (const componentId of Array.isArray(request.excludeComponents) ? request.excludeComponents : []) {
args.push('--without', componentId);
}
for (const language of Array.isArray(request.legacyLanguages) ? request.legacyLanguages : []) {
args.push(language);
}
return args;
}
function determineInstallCwd(record, repoRoot) {
if (record.adapter.kind === 'project') {
return path.dirname(record.state.target.root);
}
return repoRoot;
}
function validateRepoRoot(repoRoot) {
const normalized = path.resolve(repoRoot);
const packageJsonPath = path.join(normalized, 'package.json');
const installApplyPath = path.join(normalized, 'scripts', 'install-apply.js');
if (!fs.existsSync(packageJsonPath)) {
throw new Error(`Invalid ECC repo root: missing package.json at ${packageJsonPath}`);
}
if (!fs.existsSync(installApplyPath)) {
throw new Error(`Invalid ECC repo root: missing install script at ${installApplyPath}`);
}
return normalized;
}
function runExternalCommand(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
env: options.env || process.env,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
});
if (result.error) {
throw result.error;
}
if (typeof result.status === 'number' && result.status !== 0) {
const errorOutput = (result.stderr || result.stdout || '').trim();
throw new Error(`${command} ${args.join(' ')} failed${errorOutput ? `: ${errorOutput}` : ''}`);
}
return result;
}
function runAutoUpdate(options = {}, dependencies = {}) {
const discover = dependencies.discoverInstalledStates || discoverInstalledStates;
const execute = dependencies.runExternalCommand || runExternalCommand;
const homeDir = options.homeDir || process.env.HOME || os.homedir();
const projectRoot = options.projectRoot || process.cwd();
const requestedRepoRoot = options.repoRoot ? validateRepoRoot(options.repoRoot) : null;
const records = discover({
homeDir,
projectRoot,
targets: options.targets,
}).filter(record => record.exists);
const results = [];
if (records.length === 0) {
return {
dryRun: Boolean(options.dryRun),
repoRoot: requestedRepoRoot,
results,
summary: {
checkedCount: 0,
updatedCount: 0,
errorCount: 0,
},
};
}
const validRecords = [];
const inferredRepoRoots = [];
for (const record of records) {
if (record.error || !record.state) {
results.push({
adapter: record.adapter,
installStatePath: record.installStatePath,
status: 'error',
error: record.error || 'No valid install-state available',
});
continue;
}
const recordRepoRoot = requestedRepoRoot || validateRepoRoot(deriveRepoRootFromState(record.state));
inferredRepoRoots.push(recordRepoRoot);
validRecords.push({
record,
repoRoot: recordRepoRoot,
});
}
if (!requestedRepoRoot) {
const uniqueRepoRoots = [...new Set(inferredRepoRoots)];
if (uniqueRepoRoots.length > 1) {
throw new Error(`Multiple ECC repo roots detected: ${uniqueRepoRoots.join(', ')}`);
}
}
const repoRoot = requestedRepoRoot || inferredRepoRoots[0] || null;
if (!repoRoot) {
return {
dryRun: Boolean(options.dryRun),
repoRoot,
results,
summary: {
checkedCount: results.length,
updatedCount: 0,
errorCount: results.length,
},
};
}
const env = {
...process.env,
HOME: homeDir,
USERPROFILE: homeDir,
};
if (!options.dryRun) {
execute('git', ['fetch', '--all', '--prune'], { cwd: repoRoot, env });
execute('git', ['pull', '--ff-only'], { cwd: repoRoot, env });
}
for (const entry of validRecords) {
const installArgs = buildInstallApplyArgs(entry.record);
const args = [
path.join(repoRoot, 'scripts', 'install-apply.js'),
...installArgs,
'--json',
];
if (options.dryRun) {
args.push('--dry-run');
}
try {
const commandResult = execute(process.execPath, args, {
cwd: determineInstallCwd(entry.record, repoRoot),
env,
});
let payload = null;
if (commandResult.stdout && commandResult.stdout.trim()) {
payload = JSON.parse(commandResult.stdout);
}
results.push({
adapter: entry.record.adapter,
installStatePath: entry.record.installStatePath,
repoRoot,
cwd: determineInstallCwd(entry.record, repoRoot),
installArgs,
status: options.dryRun ? 'planned' : 'updated',
payload,
});
} catch (error) {
results.push({
adapter: entry.record.adapter,
installStatePath: entry.record.installStatePath,
repoRoot,
installArgs,
status: 'error',
error: error.message,
});
}
}
return {
dryRun: Boolean(options.dryRun),
repoRoot,
results,
summary: {
checkedCount: results.length,
updatedCount: results.filter(result => result.status === 'updated' || result.status === 'planned').length,
errorCount: results.filter(result => result.status === 'error').length,
},
};
}
function printHuman(result) {
if (result.results.length === 0) {
console.log('No ECC install-state files found for the current home/project context.');
return;
}
console.log(`${result.dryRun ? 'Auto-update dry run' : 'Auto-update summary'}:\n`);
if (result.repoRoot) {
console.log(`Repo root: ${result.repoRoot}\n`);
}
for (const entry of result.results) {
console.log(`- ${entry.adapter.id}`);
console.log(` Status: ${entry.status.toUpperCase()}`);
console.log(` Install-state: ${entry.installStatePath}`);
if (entry.error) {
console.log(` Error: ${entry.error}`);
continue;
}
console.log(` Reinstall args: ${entry.installArgs.join(' ') || '(none)'}`);
}
console.log(`\nSummary: checked=${result.summary.checkedCount}, ${result.dryRun ? 'planned' : 'updated'}=${result.summary.updatedCount}, errors=${result.summary.errorCount}`);
}
function main() {
try {
const options = parseArgs(process.argv);
if (options.help) {
showHelp(0);
}
const result = runAutoUpdate({
homeDir: process.env.HOME || os.homedir(),
projectRoot: process.cwd(),
targets: options.targets,
repoRoot: options.repoRoot,
dryRun: options.dryRun,
});
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else {
printHuman(result);
}
process.exitCode = result.summary.errorCount > 0 ? 1 : 0;
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = {
parseArgs,
deriveRepoRootFromState,
buildInstallApplyArgs,
determineInstallCwd,
runAutoUpdate,
};
-138
View File
@@ -1,138 +0,0 @@
#!/usr/bin/env node
/**
* Reject unsafe GitHub Actions patterns that execute or checkout untrusted PR code
* from privileged events such as workflow_run or pull_request_target.
*/
const fs = require('fs');
const path = require('path');
const DEFAULT_WORKFLOWS_DIR = path.join(__dirname, '../../.github/workflows');
const RULES = [
{
event: 'workflow_run',
eventPattern: /\bworkflow_run\s*:/m,
description: 'workflow_run must not checkout an untrusted workflow_run head ref/repository',
expressionPattern: /\$\{\{\s*github\.event\.workflow_run\.(?:head_branch|head_sha|head_repository(?:\.[A-Za-z0-9_.]+)?)\s*\}\}|\$\{\{\s*github\.event\.workflow_run\.pull_requests\[\d+\]\.head\.(?:ref|sha|repo\.full_name)\s*\}\}/g,
},
{
event: 'pull_request_target',
eventPattern: /\bpull_request_target\s*:/m,
description: 'pull_request_target must not checkout an untrusted pull_request head ref/repository',
expressionPattern: /\$\{\{\s*github\.event\.pull_request\.head\.(?:ref|sha|repo\.full_name)\s*\}\}/g,
},
];
function getWorkflowFiles(workflowsDir) {
if (!fs.existsSync(workflowsDir)) {
return [];
}
return fs.readdirSync(workflowsDir)
.filter(file => /\.(?:yml|yaml)$/i.test(file))
.map(file => path.join(workflowsDir, file))
.sort();
}
function getLineNumber(source, index) {
return source.slice(0, index).split(/\r?\n/).length;
}
function extractCheckoutSteps(source) {
const blocks = [];
const lines = source.split(/\r?\n/);
let current = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const stepStart = line.match(/^(\s*)-\s+/);
if (stepStart) {
if (current) {
blocks.push(current);
}
current = {
indent: stepStart[1].length,
startLine: i + 1,
lines: [line],
};
continue;
}
if (current) {
current.lines.push(line);
}
}
if (current) {
blocks.push(current);
}
return blocks
.map(block => ({
startLine: block.startLine,
text: block.lines.join('\n'),
}))
.filter(block => /uses:\s*actions\/checkout@/m.test(block.text));
}
function findViolations(filePath, source) {
const violations = [];
const checkoutSteps = extractCheckoutSteps(source);
for (const rule of RULES) {
if (!rule.eventPattern.test(source)) {
continue;
}
for (const step of checkoutSteps) {
for (const match of step.text.matchAll(rule.expressionPattern)) {
violations.push({
filePath,
event: rule.event,
description: rule.description,
expression: match[0],
line: step.startLine + getLineNumber(step.text, match.index) - 1,
});
}
}
}
return violations;
}
function validateWorkflowSecurity(workflowsDir = DEFAULT_WORKFLOWS_DIR) {
const files = getWorkflowFiles(workflowsDir);
const violations = [];
for (const filePath of files) {
const source = fs.readFileSync(filePath, 'utf8');
violations.push(...findViolations(filePath, source));
}
if (violations.length > 0) {
for (const violation of violations) {
console.error(
`ERROR: ${path.basename(violation.filePath)}:${violation.line} - ${violation.description}`,
);
console.error(` Unsafe expression: ${violation.expression}`);
}
return 1;
}
console.log(`Validated workflow security for ${files.length} workflow files`);
return 0;
}
if (require.main === module) {
process.exit(validateWorkflowSecurity(process.env.ECC_WORKFLOWS_DIR || DEFAULT_WORKFLOWS_DIR));
}
module.exports = {
DEFAULT_WORKFLOWS_DIR,
extractCheckoutSteps,
findViolations,
validateWorkflowSecurity,
};
-6
View File
@@ -33,10 +33,6 @@ const COMMANDS = {
script: 'repair.js',
description: 'Restore drifted or missing ECC-managed files',
},
'auto-update': {
script: 'auto-update.js',
description: 'Pull latest ECC changes and reinstall the current managed targets',
},
status: {
script: 'status.js',
description: 'Query the ECC SQLite state store status summary',
@@ -62,7 +58,6 @@ const PRIMARY_COMMANDS = [
'list-installed',
'doctor',
'repair',
'auto-update',
'status',
'sessions',
'session-inspect',
@@ -95,7 +90,6 @@ Examples:
ecc list-installed --json
ecc doctor --target cursor
ecc repair --dry-run
ecc auto-update --dry-run
ecc status --json
ecc sessions
ecc sessions session-active --json
-269
View File
@@ -1,269 +0,0 @@
#!/usr/bin/env node
/**
* PreToolUse Hook: Block --no-verify flag
*
* Blocks git hook-bypass flags (--no-verify, -c core.hooksPath=) to protect
* pre-commit, commit-msg, and pre-push hooks from being skipped by AI agents.
*
* Replaces the previous npx-based invocation that failed in pnpm-only projects
* (EBADDEVENGINES) and could not be disabled via ECC_DISABLED_HOOKS.
*
* Exit codes:
* 0 = allow (not a git command or no bypass flags)
* 2 = block (bypass flag detected)
*/
'use strict';
const MAX_STDIN = 1024 * 1024;
let raw = '';
/**
* Git commands that support the --no-verify flag.
*/
const GIT_COMMANDS_WITH_NO_VERIFY = [
'commit',
'push',
'merge',
'cherry-pick',
'rebase',
'am',
];
/**
* Characters that can appear immediately before 'git' in a command string.
*/
const VALID_BEFORE_GIT = ' \t\n\r;&|$`(<{!"\']/.~\\';
/**
* Check if a position in the input is inside a shell comment.
*/
function isInComment(input, idx) {
const lineStart = input.lastIndexOf('\n', idx - 1) + 1;
const before = input.slice(lineStart, idx);
for (let i = 0; i < before.length; i++) {
if (before.charAt(i) === '#') {
const prev = i > 0 ? before.charAt(i - 1) : '';
if (prev !== '$' && prev !== '\\') return true;
}
}
return false;
}
/**
* Find the next 'git' token in the input starting from a position.
*/
function findGit(input, start) {
let pos = start;
while (pos < input.length) {
const idx = input.indexOf('git', pos);
if (idx === -1) return null;
const isExe = input.slice(idx + 3, idx + 7).toLowerCase() === '.exe';
const len = isExe ? 7 : 3;
const after = input[idx + len] || ' ';
if (!/[\s"']/.test(after)) {
pos = idx + 1;
continue;
}
const before = idx > 0 ? input[idx - 1] : ' ';
if (VALID_BEFORE_GIT.includes(before)) return { idx, len };
pos = idx + 1;
}
return null;
}
/**
* Detect which git subcommand (commit, push, etc.) is being invoked.
* Returns { command, offset } where offset is the position right after the
* subcommand keyword, so callers can scope flag checks to only that portion.
*/
function detectGitCommand(input) {
let start = 0;
while (start < input.length) {
const git = findGit(input, start);
if (!git) return null;
if (isInComment(input, git.idx)) {
start = git.idx + git.len;
continue;
}
// Find the first matching subcommand token after "git".
// We pick the one closest to "git" so that argument values like
// "git push origin commit" don't misclassify "commit" as the subcommand.
let bestCmd = null;
let bestIdx = Infinity;
for (const cmd of GIT_COMMANDS_WITH_NO_VERIFY) {
let searchPos = git.idx + git.len;
while (searchPos < input.length) {
const cmdIdx = input.indexOf(cmd, searchPos);
if (cmdIdx === -1) break;
const before = cmdIdx > 0 ? input[cmdIdx - 1] : ' ';
const after = input[cmdIdx + cmd.length] || ' ';
if (!/\s/.test(before)) { searchPos = cmdIdx + 1; continue; }
if (!/[\s;&#|>)\]}"']/.test(after) && after !== '') { searchPos = cmdIdx + 1; continue; }
if (/[;|]/.test(input.slice(git.idx + git.len, cmdIdx))) break;
if (isInComment(input, cmdIdx)) { searchPos = cmdIdx + 1; continue; }
// Verify this token is the first non-flag word after "git" — i.e. the
// actual subcommand, not an argument value to a different subcommand.
const gap = input.slice(git.idx + git.len, cmdIdx);
const tokens = gap.trim().split(/\s+/).filter(Boolean);
// Every token before the candidate must be a flag or a flag argument.
// Git global flags like -c take a value argument (e.g. -c key=value).
let onlyFlagsAndArgs = true;
let expectFlagArg = false;
for (const t of tokens) {
if (expectFlagArg) { expectFlagArg = false; continue; }
if (t.startsWith('-')) {
// -c is a git global flag that takes the next token as its argument
if (t === '-c' || t === '-C' || t === '--work-tree' || t === '--git-dir' ||
t === '--namespace' || t === '--super-prefix') {
expectFlagArg = true;
}
continue;
}
onlyFlagsAndArgs = false;
break;
}
if (!onlyFlagsAndArgs) { searchPos = cmdIdx + 1; continue; }
if (cmdIdx < bestIdx) {
bestIdx = cmdIdx;
bestCmd = cmd;
}
break;
}
}
if (bestCmd) {
return { command: bestCmd, offset: bestIdx + bestCmd.length };
}
start = git.idx + git.len;
}
return null;
}
/**
* Check if the input contains a --no-verify flag for a specific git command.
* Only inspects the portion of the input starting at `offset` (the position
* right after the detected subcommand keyword) so that flags belonging to
* earlier commands in a chain are not falsely matched.
*/
function hasNoVerifyFlag(input, command, offset) {
const region = input.slice(offset);
if (/--no-verify\b/.test(region)) return true;
// For commit, -n is shorthand for --no-verify
if (command === 'commit') {
if (/\s-n(?:\s|$)/.test(region) || /\s-n[a-zA-Z]/.test(region)) return true;
}
return false;
}
/**
* Check if the input contains a -c core.hooksPath= override.
*/
function hasHooksPathOverride(input) {
return /-c\s+["']?core\.hooksPath\s*=/.test(input);
}
/**
* Check a command string for git hook bypass attempts.
*/
function checkCommand(input) {
const detected = detectGitCommand(input);
if (!detected) return { blocked: false };
const { command: gitCommand, offset } = detected;
if (hasNoVerifyFlag(input, gitCommand, offset)) {
return {
blocked: true,
reason: `BLOCKED: --no-verify flag is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`,
};
}
if (hasHooksPathOverride(input)) {
return {
blocked: true,
reason: `BLOCKED: Overriding core.hooksPath is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`,
};
}
return { blocked: false };
}
/**
* Extract the command string from hook input (JSON or plain text).
*/
function extractCommand(rawInput) {
const trimmed = rawInput.trim();
if (!trimmed.startsWith('{')) return trimmed;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== 'object' || parsed === null) return trimmed;
// Claude Code format: { tool_input: { command: "..." } }
const cmd = parsed.tool_input?.command;
if (typeof cmd === 'string') return cmd;
// Generic JSON formats
for (const key of ['command', 'cmd', 'input', 'shell', 'script']) {
if (typeof parsed[key] === 'string') return parsed[key];
}
return trimmed;
} catch {
return trimmed;
}
}
/**
* Exportable run() for in-process execution via run-with-flags.js.
*/
function run(rawInput) {
const command = extractCommand(rawInput);
const result = checkCommand(command);
if (result.blocked) {
return {
exitCode: 2,
stderr: result.reason,
};
}
return { exitCode: 0 };
}
module.exports = { run };
// Stdin fallback for spawnSync execution — only when invoked directly, not via require()
if (require.main === module) {
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const command = extractCommand(raw);
const result = checkCommand(command);
if (result.blocked) {
process.stderr.write(result.reason + '\n');
process.exit(2);
}
process.stdout.write(raw);
});
}
+1 -1
View File
@@ -55,7 +55,7 @@ process.stdin.on('end', () => {
const outputTokens = toNumber(usage.output_tokens || usage.completion_tokens || 0);
const model = String(input.model || input._cursor?.model || process.env.CLAUDE_MODEL || 'unknown');
const sessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default');
const sessionId = String(process.env.CLAUDE_SESSION_ID || 'default');
const metricsDir = path.join(getClaudeDir(), 'metrics');
ensureDir(metricsDir);
-639
View File
@@ -1,639 +0,0 @@
#!/usr/bin/env node
/**
* Session Activity Tracker Hook
*
* PostToolUse hook that records sanitized per-tool activity to
* ~/.claude/metrics/tool-usage.jsonl for ECC2 metric sync.
*/
'use strict';
const crypto = require('crypto');
const path = require('path');
const { spawnSync } = require('child_process');
const {
appendFile,
getClaudeDir,
stripAnsi,
} = require('../lib/utils');
const MAX_STDIN = 1024 * 1024;
const METRICS_FILE_NAME = 'tool-usage.jsonl';
const FILE_PATH_KEYS = new Set([
'file_path',
'file_paths',
'source_path',
'destination_path',
'old_file_path',
'new_file_path',
]);
function redactSecrets(value) {
return String(value || '')
.replace(/\n/g, ' ')
.replace(/--token[= ][^ ]*/g, '--token=<REDACTED>')
.replace(/Authorization:[: ]*[^ ]*[: ]*[^ ]*/gi, 'Authorization:<REDACTED>')
.replace(/\bAKIA[A-Z0-9]{16}\b/g, '<REDACTED>')
.replace(/\bASIA[A-Z0-9]{16}\b/g, '<REDACTED>')
.replace(/password[= ][^ ]*/gi, 'password=<REDACTED>')
.replace(/\bghp_[A-Za-z0-9_]+\b/g, '<REDACTED>')
.replace(/\bgho_[A-Za-z0-9_]+\b/g, '<REDACTED>')
.replace(/\bghs_[A-Za-z0-9_]+\b/g, '<REDACTED>')
.replace(/\bgithub_pat_[A-Za-z0-9_]+\b/g, '<REDACTED>');
}
function truncateSummary(value, maxLength = 220) {
const normalized = stripAnsi(redactSecrets(value)).trim().replace(/\s+/g, ' ');
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 3)}...`;
}
function sanitizeParamValue(value, depth = 0) {
if (depth >= 4) {
return '[Truncated]';
}
if (value === null || value === undefined) {
return value;
}
if (typeof value === 'string') {
return truncateSummary(value, 160);
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (Array.isArray(value)) {
return value.slice(0, 8).map(entry => sanitizeParamValue(entry, depth + 1));
}
if (typeof value === 'object') {
const output = {};
for (const [key, nested] of Object.entries(value).slice(0, 20)) {
output[key] = sanitizeParamValue(nested, depth + 1);
}
return output;
}
return truncateSummary(String(value), 160);
}
function sanitizeInputParams(toolInput) {
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) {
return '{}';
}
try {
return JSON.stringify(sanitizeParamValue(toolInput));
} catch {
return '{}';
}
}
function pushPathCandidate(paths, value) {
const candidate = String(value || '').trim();
if (!candidate) {
return;
}
if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) {
return;
}
if (!paths.includes(candidate)) {
paths.push(candidate);
}
}
function pushFileEvent(events, value, action, diffPreview, patchPreview) {
const candidate = String(value || '').trim();
if (!candidate) {
return;
}
if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) {
return;
}
const normalizedDiffPreview = typeof diffPreview === 'string' && diffPreview.trim()
? diffPreview.trim()
: undefined;
const normalizedPatchPreview = typeof patchPreview === 'string' && patchPreview.trim()
? patchPreview.trim()
: undefined;
if (!events.some(event =>
event.path === candidate
&& event.action === action
&& (event.diff_preview || undefined) === normalizedDiffPreview
&& (event.patch_preview || undefined) === normalizedPatchPreview
)) {
const event = { path: candidate, action };
if (normalizedDiffPreview) {
event.diff_preview = normalizedDiffPreview;
}
if (normalizedPatchPreview) {
event.patch_preview = normalizedPatchPreview;
}
events.push(event);
}
}
function sanitizeDiffText(value, maxLength = 96) {
if (typeof value !== 'string' || !value.trim()) {
return '';
}
return truncateSummary(value, maxLength);
}
function sanitizePatchLines(value, maxLines = 4, maxLineLength = 120) {
if (typeof value !== 'string' || !value.trim()) {
return [];
}
return stripAnsi(redactSecrets(value))
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean)
.slice(0, maxLines)
.map(line => line.length <= maxLineLength ? line : `${line.slice(0, maxLineLength - 3)}...`);
}
function buildReplacementPreview(oldValue, newValue) {
const before = sanitizeDiffText(oldValue);
const after = sanitizeDiffText(newValue);
if (!before && !after) {
return undefined;
}
if (!before) {
return `-> ${after}`;
}
if (!after) {
return `${before} ->`;
}
return `${before} -> ${after}`;
}
function buildCreationPreview(content) {
const normalized = sanitizeDiffText(content);
if (!normalized) {
return undefined;
}
return `+ ${normalized}`;
}
function buildPatchPreviewFromReplacement(oldValue, newValue) {
const beforeLines = sanitizePatchLines(oldValue);
const afterLines = sanitizePatchLines(newValue);
if (beforeLines.length === 0 && afterLines.length === 0) {
return undefined;
}
const lines = ['@@'];
for (const line of beforeLines) {
lines.push(`- ${line}`);
}
for (const line of afterLines) {
lines.push(`+ ${line}`);
}
return lines.join('\n');
}
function buildPatchPreviewFromContent(content, prefix) {
const lines = sanitizePatchLines(content);
if (lines.length === 0) {
return undefined;
}
return lines.map(line => `${prefix} ${line}`).join('\n');
}
function buildDiffPreviewFromPatchPreview(patchPreview) {
if (typeof patchPreview !== 'string' || !patchPreview.trim()) {
return undefined;
}
const lines = patchPreview
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean);
const removed = lines.find(line => line.startsWith('- ') || line.startsWith('-'));
const added = lines.find(line => line.startsWith('+ ') || line.startsWith('+'));
if (!removed && !added) {
return undefined;
}
const before = removed ? removed.replace(/^- ?/, '') : '';
const after = added ? added.replace(/^\+ ?/, '') : '';
if (before && after) {
return `${before} -> ${after}`;
}
if (before) {
return `${before} ->`;
}
return `-> ${after}`;
}
function inferDefaultFileAction(toolName) {
const normalized = String(toolName || '').trim().toLowerCase();
if (normalized.includes('read')) {
return 'read';
}
if (normalized.includes('write')) {
return 'create';
}
if (normalized.includes('edit')) {
return 'modify';
}
if (normalized.includes('delete') || normalized.includes('remove')) {
return 'delete';
}
if (normalized.includes('move') || normalized.includes('rename')) {
return 'move';
}
return 'touch';
}
function actionForFileKey(toolName, key) {
if (key === 'source_path' || key === 'old_file_path') {
return 'move';
}
if (key === 'destination_path' || key === 'new_file_path') {
return 'move';
}
return inferDefaultFileAction(toolName);
}
function collectFilePaths(value, paths) {
if (!value) {
return;
}
if (Array.isArray(value)) {
for (const entry of value) {
collectFilePaths(entry, paths);
}
return;
}
if (typeof value === 'string') {
pushPathCandidate(paths, value);
return;
}
if (typeof value !== 'object') {
return;
}
for (const [key, nested] of Object.entries(value)) {
if (FILE_PATH_KEYS.has(key)) {
collectFilePaths(nested, paths);
continue;
}
if (nested && (Array.isArray(nested) || typeof nested === 'object')) {
collectFilePaths(nested, paths);
}
}
}
function extractFilePaths(toolInput) {
const paths = [];
if (!toolInput || typeof toolInput !== 'object') {
return paths;
}
collectFilePaths(toolInput, paths);
return paths;
}
function fileEventDiffPreview(toolName, value, action) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
if (typeof value.old_string === 'string' || typeof value.new_string === 'string') {
return buildReplacementPreview(value.old_string, value.new_string);
}
if (action === 'create') {
return buildCreationPreview(value.content || value.file_text || value.text);
}
return undefined;
}
function fileEventPatchPreview(value, action) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
if (typeof value.old_string === 'string' || typeof value.new_string === 'string') {
return buildPatchPreviewFromReplacement(value.old_string, value.new_string);
}
if (action === 'create') {
return buildPatchPreviewFromContent(value.content || value.file_text || value.text, '+');
}
if (action === 'delete') {
return buildPatchPreviewFromContent(value.content || value.old_string || value.file_text, '-');
}
return undefined;
}
function runGit(args, cwd) {
const result = spawnSync('git', args, {
cwd,
encoding: 'utf8',
timeout: 2500,
});
if (result.error || result.status !== 0) {
return null;
}
return String(result.stdout || '').trim();
}
function gitRepoRoot(cwd) {
return runGit(['rev-parse', '--show-toplevel'], cwd);
}
const MAX_RELEVANT_PATCH_LINES = 6;
function candidateGitPaths(repoRoot, filePath) {
const resolvedRepoRoot = path.resolve(repoRoot);
const candidates = [];
const pushCandidate = value => {
const candidate = String(value || '').trim();
if (!candidate || candidates.includes(candidate)) {
return;
}
candidates.push(candidate);
};
const absoluteCandidates = path.isAbsolute(filePath)
? [path.resolve(filePath)]
: [
path.resolve(resolvedRepoRoot, filePath),
path.resolve(process.cwd(), filePath),
];
for (const absolute of absoluteCandidates) {
const relative = path.relative(resolvedRepoRoot, absolute);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
continue;
}
pushCandidate(relative);
pushCandidate(relative.split(path.sep).join('/'));
pushCandidate(absolute);
pushCandidate(absolute.split(path.sep).join('/'));
}
return candidates;
}
function patchPreviewFromGitDiff(repoRoot, pathCandidates) {
for (const candidate of pathCandidates) {
const patch = runGit(
['diff', '--no-ext-diff', '--no-color', '--unified=1', '--', candidate],
repoRoot
);
if (!patch) {
continue;
}
const relevant = patch
.split(/\r?\n/)
.filter(line =>
line.startsWith('@@')
|| (line.startsWith('+') && !line.startsWith('+++'))
|| (line.startsWith('-') && !line.startsWith('---'))
)
.slice(0, MAX_RELEVANT_PATCH_LINES);
if (relevant.length > 0) {
return relevant.join('\n');
}
}
return undefined;
}
function trackedInGit(repoRoot, pathCandidates) {
return pathCandidates.some(candidate =>
runGit(['ls-files', '--error-unmatch', '--', candidate], repoRoot) !== null
);
}
function enrichFileEventFromWorkingTree(toolName, event) {
if (!event || typeof event !== 'object' || !event.path) {
return event;
}
const repoRoot = gitRepoRoot(process.cwd());
if (!repoRoot) {
return event;
}
const pathCandidates = candidateGitPaths(repoRoot, event.path);
if (pathCandidates.length === 0) {
return event;
}
const tool = String(toolName || '').trim().toLowerCase();
const tracked = trackedInGit(repoRoot, pathCandidates);
const patchPreview = patchPreviewFromGitDiff(repoRoot, pathCandidates) || event.patch_preview;
const diffPreview = buildDiffPreviewFromPatchPreview(patchPreview) || event.diff_preview;
if (tool.includes('write')) {
return {
...event,
action: tracked ? 'modify' : event.action,
diff_preview: diffPreview,
patch_preview: patchPreview,
};
}
if (tracked && patchPreview) {
return {
...event,
diff_preview: diffPreview,
patch_preview: patchPreview,
};
}
return event;
}
function collectFileEvents(toolName, value, events, key = null, parentValue = null) {
if (!value) {
return;
}
if (Array.isArray(value)) {
for (const entry of value) {
collectFileEvents(toolName, entry, events, key, parentValue);
}
return;
}
if (typeof value === 'string') {
if (key && FILE_PATH_KEYS.has(key)) {
const action = actionForFileKey(toolName, key);
pushFileEvent(
events,
value,
action,
fileEventDiffPreview(toolName, parentValue, action),
fileEventPatchPreview(parentValue, action)
);
}
return;
}
if (typeof value !== 'object') {
return;
}
for (const [nestedKey, nested] of Object.entries(value)) {
if (FILE_PATH_KEYS.has(nestedKey)) {
collectFileEvents(toolName, nested, events, nestedKey, value);
continue;
}
if (nested && (Array.isArray(nested) || typeof nested === 'object')) {
collectFileEvents(toolName, nested, events, null, nested);
}
}
}
function extractFileEvents(toolName, toolInput) {
const events = [];
if (!toolInput || typeof toolInput !== 'object') {
return events;
}
collectFileEvents(toolName, toolInput, events);
return events;
}
function summarizeInput(toolName, toolInput, filePaths) {
if (toolName === 'Bash') {
return truncateSummary(toolInput?.command || 'bash');
}
if (filePaths.length > 0) {
return truncateSummary(`${toolName} ${filePaths.join(', ')}`);
}
if (toolInput && typeof toolInput === 'object') {
const shallow = {};
for (const [key, value] of Object.entries(toolInput)) {
if (value === null || value === undefined) {
continue;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
shallow[key] = value;
}
}
const serialized = Object.keys(shallow).length > 0 ? JSON.stringify(shallow) : toolName;
return truncateSummary(serialized);
}
return truncateSummary(toolName);
}
function summarizeOutput(toolOutput) {
if (toolOutput === null || toolOutput === undefined) {
return '';
}
if (typeof toolOutput === 'string') {
return truncateSummary(toolOutput);
}
if (typeof toolOutput === 'object' && typeof toolOutput.output === 'string') {
return truncateSummary(toolOutput.output);
}
return truncateSummary(JSON.stringify(toolOutput));
}
function buildActivityRow(input, env = process.env) {
const hookEvent = String(env.CLAUDE_HOOK_EVENT_NAME || '').trim();
if (hookEvent && hookEvent !== 'PostToolUse') {
return null;
}
const toolName = String(input?.tool_name || '').trim();
const sessionId = String(env.ECC_SESSION_ID || env.CLAUDE_SESSION_ID || '').trim();
if (!toolName || !sessionId) {
return null;
}
const toolInput = input?.tool_input || {};
const fileEvents = extractFileEvents(toolName, toolInput).map(event =>
enrichFileEventFromWorkingTree(toolName, event)
);
const filePaths = fileEvents.length > 0
? [...new Set(fileEvents.map(event => event.path))]
: extractFilePaths(toolInput);
return {
id: `tool-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`,
timestamp: new Date().toISOString(),
session_id: sessionId,
tool_name: toolName,
input_summary: summarizeInput(toolName, toolInput, filePaths),
input_params_json: sanitizeInputParams(toolInput),
output_summary: summarizeOutput(input?.tool_output),
duration_ms: 0,
file_paths: filePaths,
file_events: fileEvents,
};
}
function run(rawInput) {
try {
const input = rawInput.trim() ? JSON.parse(rawInput) : {};
const row = buildActivityRow(input);
if (row) {
appendFile(
path.join(getClaudeDir(), 'metrics', METRICS_FILE_NAME),
`${JSON.stringify(row)}\n`
);
}
} catch {
// Keep hook non-blocking.
}
return rawInput;
}
function main() {
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
process.stdout.write(run(raw));
});
}
if (require.main === module) {
main();
}
module.exports = {
buildActivityRow,
extractFileEvents,
extractFilePaths,
summarizeInput,
summarizeOutput,
run,
};
+113 -9
View File
@@ -46,6 +46,80 @@ function replacePluginRootPlaceholders(value, pluginRoot) {
return value;
}
function buildLegacyHookSignature(entry, pluginRoot) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
return null;
}
const normalizedEntry = replacePluginRootPlaceholders(entry, pluginRoot);
if (typeof normalizedEntry.matcher !== 'string' || !Array.isArray(normalizedEntry.hooks)) {
return null;
}
const hookSignature = normalizedEntry.hooks.map(hook => JSON.stringify({
type: hook && typeof hook === 'object' ? hook.type : undefined,
command: hook && typeof hook === 'object' ? hook.command : undefined,
timeout: hook && typeof hook === 'object' ? hook.timeout : undefined,
async: hook && typeof hook === 'object' ? hook.async : undefined,
}));
return JSON.stringify({
matcher: normalizedEntry.matcher,
hooks: hookSignature,
});
}
function getHookEntryAliases(entry, pluginRoot) {
const aliases = [];
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
return aliases;
}
const normalizedEntry = replacePluginRootPlaceholders(entry, pluginRoot);
if (typeof normalizedEntry.id === 'string' && normalizedEntry.id.trim().length > 0) {
aliases.push(`id:${normalizedEntry.id.trim()}`);
}
const legacySignature = buildLegacyHookSignature(normalizedEntry, pluginRoot);
if (legacySignature) {
aliases.push(`legacy:${legacySignature}`);
}
aliases.push(`json:${JSON.stringify(normalizedEntry)}`);
return aliases;
}
function mergeHookEntries(existingEntries, incomingEntries, pluginRoot) {
const mergedEntries = [];
const seenEntries = new Set();
for (const entry of [...existingEntries, ...incomingEntries]) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
continue;
}
if ('id' in entry && typeof entry.id !== 'string') {
continue;
}
const aliases = getHookEntryAliases(entry, pluginRoot);
if (aliases.some(alias => seenEntries.has(alias))) {
continue;
}
for (const alias of aliases) {
seenEntries.add(alias);
}
mergedEntries.push(replacePluginRootPlaceholders(entry, pluginRoot));
}
return mergedEntries;
}
function findHooksSourcePath(plan, hooksDestinationPath) {
const operation = plan.operations.find(item => item.destinationPath === hooksDestinationPath);
return operation ? operation.sourcePath : null;
@@ -94,7 +168,7 @@ function buildFilteredMcpWrites(plan) {
return writes;
}
function buildResolvedClaudeHooks(plan) {
function buildMergedSettings(plan) {
if (!plan.adapter || plan.adapter.target !== 'claude') {
return null;
}
@@ -107,22 +181,46 @@ function buildResolvedClaudeHooks(plan) {
}
const hooksConfig = readJsonObject(hooksSourcePath, 'hooks config');
const resolvedHooks = replacePluginRootPlaceholders(hooksConfig.hooks, pluginRoot);
if (!resolvedHooks || typeof resolvedHooks !== 'object' || Array.isArray(resolvedHooks)) {
const incomingHooks = replacePluginRootPlaceholders(hooksConfig.hooks, pluginRoot);
if (!incomingHooks || typeof incomingHooks !== 'object' || Array.isArray(incomingHooks)) {
throw new Error(`Invalid hooks config at ${hooksSourcePath}: expected "hooks" to be a JSON object`);
}
const settingsPath = path.join(plan.targetRoot, 'settings.json');
let settings = {};
if (fs.existsSync(settingsPath)) {
settings = readJsonObject(settingsPath, 'existing settings');
}
const existingHooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
? settings.hooks
: {};
const mergedHooks = { ...existingHooks };
for (const [eventName, incomingEntries] of Object.entries(incomingHooks)) {
const currentEntries = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
const nextEntries = Array.isArray(incomingEntries) ? incomingEntries : [];
mergedHooks[eventName] = mergeHookEntries(currentEntries, nextEntries, pluginRoot);
}
const mergedSettings = {
...settings,
hooks: mergedHooks,
};
return {
settingsPath,
mergedSettings,
hooksDestinationPath,
resolvedHooksConfig: {
...hooksConfig,
hooks: resolvedHooks,
hooks: incomingHooks,
},
};
}
function applyInstallPlan(plan) {
const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(plan);
const mergedSettingsPlan = buildMergedSettings(plan);
const filteredMcpWrites = buildFilteredMcpWrites(plan);
for (const operation of plan.operations) {
@@ -130,11 +228,17 @@ function applyInstallPlan(plan) {
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
}
if (resolvedClaudeHooksPlan) {
fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true });
if (mergedSettingsPlan) {
fs.mkdirSync(path.dirname(mergedSettingsPlan.hooksDestinationPath), { recursive: true });
fs.writeFileSync(
resolvedClaudeHooksPlan.hooksDestinationPath,
JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n',
mergedSettingsPlan.hooksDestinationPath,
JSON.stringify(mergedSettingsPlan.resolvedHooksConfig, null, 2) + '\n',
'utf8'
);
fs.mkdirSync(path.dirname(mergedSettingsPlan.settingsPath), { recursive: true });
fs.writeFileSync(
mergedSettingsPlan.settingsPath,
JSON.stringify(mergedSettingsPlan.mergedSettings, null, 2) + '\n',
'utf8'
);
}
+11 -172
View File
@@ -6,21 +6,9 @@ set -euo pipefail
VERSION="${1:-}"
ROOT_PACKAGE_JSON="package.json"
PACKAGE_LOCK_JSON="package-lock.json"
ROOT_AGENTS_MD="AGENTS.md"
TR_AGENTS_MD="docs/tr/AGENTS.md"
ZH_CN_AGENTS_MD="docs/zh-CN/AGENTS.md"
AGENT_YAML="agent.yaml"
VERSION_FILE="VERSION"
PLUGIN_JSON=".claude-plugin/plugin.json"
MARKETPLACE_JSON=".claude-plugin/marketplace.json"
CODEX_MARKETPLACE_JSON=".agents/plugins/marketplace.json"
CODEX_PLUGIN_JSON=".codex-plugin/plugin.json"
OPENCODE_PACKAGE_JSON=".opencode/package.json"
OPENCODE_PACKAGE_LOCK_JSON=".opencode/package-lock.json"
README_FILE="README.md"
ZH_CN_README_FILE="docs/zh-CN/README.md"
SELECTIVE_INSTALL_ARCHITECTURE_DOC="docs/SELECTIVE-INSTALL-ARCHITECTURE.md"
# Function to show usage
usage() {
@@ -48,14 +36,14 @@ if [[ "$CURRENT_BRANCH" != "main" ]]; then
exit 1
fi
# Check working tree is clean, including untracked files
if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; then
# Check working tree is clean
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Error: Working tree is not clean. Commit or stash changes first."
exit 1
fi
# Verify versioned manifests exist
for FILE in "$ROOT_PACKAGE_JSON" "$PACKAGE_LOCK_JSON" "$ROOT_AGENTS_MD" "$TR_AGENTS_MD" "$ZH_CN_AGENTS_MD" "$AGENT_YAML" "$VERSION_FILE" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$CODEX_MARKETPLACE_JSON" "$CODEX_PLUGIN_JSON" "$OPENCODE_PACKAGE_JSON" "$OPENCODE_PACKAGE_LOCK_JSON" "$README_FILE" "$ZH_CN_README_FILE" "$SELECTIVE_INSTALL_ARCHITECTURE_DOC"; do
for FILE in "$ROOT_PACKAGE_JSON" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$OPENCODE_PACKAGE_JSON"; do
if [[ ! -f "$FILE" ]]; then
echo "Error: $FILE not found"
exit 1
@@ -70,6 +58,13 @@ if [[ -z "$OLD_VERSION" ]]; then
fi
echo "Bumping version: $OLD_VERSION -> $VERSION"
# Build and verify the packaged OpenCode payload before mutating any manifest
# versions or creating a tag. This keeps a broken npm artifact from being
# released via the manual script path.
echo "Verifying OpenCode build and npm pack payload..."
node scripts/build-opencode.js
node tests/scripts/build-opencode.test.js
update_version() {
local file="$1"
local pattern="$2"
@@ -80,170 +75,14 @@ update_version() {
fi
}
update_package_lock_version() {
node -e '
const fs = require("fs");
const file = process.argv[1];
const version = process.argv[2];
const lock = JSON.parse(fs.readFileSync(file, "utf8"));
if (!lock || typeof lock !== "object") {
console.error(`Error: ${file} does not contain a JSON object`);
process.exit(1);
}
lock.version = version;
if (!lock.packages || typeof lock.packages !== "object" || Array.isArray(lock.packages)) {
console.error(`Error: ${file} is missing lock.packages`);
process.exit(1);
}
if (!lock.packages[""] || typeof lock.packages[""] !== "object" || Array.isArray(lock.packages[""])) {
console.error(`Error: ${file} is missing lock.packages[\"\"]`);
process.exit(1);
}
lock.packages[""].version = version;
fs.writeFileSync(file, `${JSON.stringify(lock, null, 2)}\n`);
' "$1" "$VERSION"
}
update_readme_version_row() {
local file="$1"
local label="$2"
local first_col="$3"
local second_col="$4"
local third_col="$5"
node -e '
const fs = require("fs");
const file = process.argv[1];
const version = process.argv[2];
const label = process.argv[3];
const firstCol = process.argv[4];
const secondCol = process.argv[5];
const thirdCol = process.argv[6];
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const current = fs.readFileSync(file, "utf8");
const updated = current.replace(
new RegExp(
`^\\| \\*\\*${escape(label)}\\*\\* \\| ${escape(firstCol)} \\| ${escape(secondCol)} \\| ${escape(thirdCol)} \\| [0-9]+\\.[0-9]+\\.[0-9]+ \\|$`,
"m"
),
`| **${label}** | ${firstCol} | ${secondCol} | ${thirdCol} | ${version} |`
);
if (updated === current) {
console.error(`Error: could not update README version row in ${file}`);
process.exit(1);
}
fs.writeFileSync(file, updated);
' "$file" "$VERSION" "$label" "$first_col" "$second_col" "$third_col"
}
update_selective_install_repo_version() {
local file="$1"
node -e '
const fs = require("fs");
const file = process.argv[1];
const version = process.argv[2];
const current = fs.readFileSync(file, "utf8");
const updated = current.replace(
/("repoVersion":\s*")[0-9][0-9.]*(")/,
`$1${version}$2`
);
if (updated === current) {
console.error(`Error: could not update repoVersion example in ${file}`);
process.exit(1);
}
fs.writeFileSync(file, updated);
' "$file" "$VERSION"
}
update_agents_version() {
local file="$1"
local label="$2"
node -e '
const fs = require("fs");
const file = process.argv[1];
const version = process.argv[2];
const label = process.argv[3];
const current = fs.readFileSync(file, "utf8");
const updated = current.replace(
new RegExp(`^\\*\\*${label}:\\*\\* [0-9][0-9.]*$`, "m"),
`**${label}:** ${version}`
);
if (updated === current) {
console.error(`Error: could not update AGENTS version line in ${file}`);
process.exit(1);
}
fs.writeFileSync(file, updated);
' "$file" "$VERSION" "$label"
}
update_agent_yaml_version() {
node -e '
const fs = require("fs");
const file = process.argv[1];
const version = process.argv[2];
const current = fs.readFileSync(file, "utf8");
const updated = current.replace(
/^version:\s*[0-9][0-9.]*$/m,
`version: ${version}`
);
if (updated === current) {
console.error(`Error: could not update agent.yaml version line in ${file}`);
process.exit(1);
}
fs.writeFileSync(file, updated);
' "$AGENT_YAML" "$VERSION"
}
update_version_file() {
printf '%s\n' "$VERSION" > "$VERSION_FILE"
}
update_codex_marketplace_version() {
node -e '
const fs = require("fs");
const file = process.argv[1];
const version = process.argv[2];
const marketplace = JSON.parse(fs.readFileSync(file, "utf8"));
if (!marketplace || typeof marketplace !== "object" || !Array.isArray(marketplace.plugins)) {
console.error(`Error: ${file} does not contain a marketplace plugins array`);
process.exit(1);
}
const plugin = marketplace.plugins.find(entry => entry && entry.name === "ecc");
if (!plugin || typeof plugin !== "object") {
console.error(`Error: could not find ecc plugin entry in ${file}`);
process.exit(1);
}
plugin.version = version;
fs.writeFileSync(file, `${JSON.stringify(marketplace, null, 2)}\n`);
' "$CODEX_MARKETPLACE_JSON" "$VERSION"
}
# Update all shipped package/plugin manifests
update_version "$ROOT_PACKAGE_JSON" "s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|"
update_package_lock_version "$PACKAGE_LOCK_JSON"
update_agents_version "$ROOT_AGENTS_MD" "Version"
update_agents_version "$TR_AGENTS_MD" "Sürüm"
update_agents_version "$ZH_CN_AGENTS_MD" "版本"
update_agent_yaml_version
update_version_file
update_version "$PLUGIN_JSON" "s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|"
update_version "$MARKETPLACE_JSON" "0,/\"version\": *\"[^\"]*\"/s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|"
update_codex_marketplace_version
update_version "$CODEX_PLUGIN_JSON" "s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|"
update_version "$OPENCODE_PACKAGE_JSON" "s|\"version\": *\"[^\"]*\"|\"version\": \"$VERSION\"|"
update_package_lock_version "$OPENCODE_PACKAGE_LOCK_JSON"
update_readme_version_row "$README_FILE" "Version" "Plugin" "Plugin" "Reference config"
update_readme_version_row "$ZH_CN_README_FILE" "版本" "插件" "插件" "参考配置"
update_selective_install_repo_version "$SELECTIVE_INSTALL_ARCHITECTURE_DOC"
# Verify the bumped release surface is still internally consistent before
# writing a release commit, tag, or push.
echo "Verifying OpenCode build and npm pack payload..."
node scripts/build-opencode.js
node tests/scripts/build-opencode.test.js
node tests/plugin-manifest.test.js
# Stage, commit, tag, and push
git add "$ROOT_PACKAGE_JSON" "$PACKAGE_LOCK_JSON" "$ROOT_AGENTS_MD" "$TR_AGENTS_MD" "$ZH_CN_AGENTS_MD" "$AGENT_YAML" "$VERSION_FILE" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$CODEX_MARKETPLACE_JSON" "$CODEX_PLUGIN_JSON" "$OPENCODE_PACKAGE_JSON" "$OPENCODE_PACKAGE_LOCK_JSON" "$README_FILE" "$ZH_CN_README_FILE" "$SELECTIVE_INSTALL_ARCHITECTURE_DOC"
git add "$ROOT_PACKAGE_JSON" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$OPENCODE_PACKAGE_JSON"
git commit -m "chore: bump plugin version to $VERSION"
git tag "v$VERSION"
git push origin main "v$VERSION"
@@ -1,90 +0,0 @@
#!/usr/bin/env node
/**
* Validate workflow security guardrails for privileged GitHub Actions events.
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const SCRIPT_PATH = path.join(__dirname, '..', '..', 'scripts', 'ci', 'validate-workflow-security.js');
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runValidator(files) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-workflow-security-'));
try {
for (const [name, contents] of Object.entries(files)) {
fs.writeFileSync(path.join(tempDir, name), contents);
}
return spawnSync('node', [SCRIPT_PATH], {
encoding: 'utf8',
env: {
...process.env,
ECC_WORKFLOWS_DIR: tempDir,
},
});
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
function run() {
console.log('\n=== Testing workflow security validation ===\n');
let passed = 0;
let failed = 0;
if (test('allows safe workflow_run workflow that only checks out the base repository', () => {
const result = runValidator({
'safe.yml': `name: Safe\non:\n workflow_run:\n workflows: ["CI"]\n types: [completed]\njobs:\n repair:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: echo safe\n`,
});
assert.strictEqual(result.status, 0, result.stderr || result.stdout);
})) passed++; else failed++;
if (test('rejects workflow_run checkout using github.event.workflow_run.head_branch', () => {
const result = runValidator({
'unsafe-workflow-run.yml': `name: Unsafe\non:\n workflow_run:\n workflows: ["CI"]\n types: [completed]\njobs:\n repair:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n with:\n ref: \${{ github.event.workflow_run.head_branch }}\n`,
});
assert.notStrictEqual(result.status, 0, 'Expected validator to fail');
assert.match(result.stderr, /workflow_run must not checkout an untrusted workflow_run head ref\/repository/);
assert.match(result.stderr, /head_branch/);
})) passed++; else failed++;
if (test('rejects workflow_run checkout using github.event.workflow_run.head_repository.full_name', () => {
const result = runValidator({
'unsafe-repository.yml': `name: Unsafe\non:\n workflow_run:\n workflows: ["CI"]\n types: [completed]\njobs:\n repair:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n with:\n repository: \${{ github.event.workflow_run.head_repository.full_name }}\n`,
});
assert.notStrictEqual(result.status, 0, 'Expected validator to fail');
assert.match(result.stderr, /head_repository\.full_name/);
})) passed++; else failed++;
if (test('rejects pull_request_target checkout using github.event.pull_request.head.sha', () => {
const result = runValidator({
'unsafe-pr-target.yml': `name: Unsafe\non:\n pull_request_target:\n branches: [main]\njobs:\n inspect:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n with:\n ref: \${{ github.event.pull_request.head.sha }}\n`,
});
assert.notStrictEqual(result.status, 0, 'Expected validator to fail');
assert.match(result.stderr, /pull_request_target must not checkout an untrusted pull_request head ref\/repository/);
assert.match(result.stderr, /pull_request\.head\.sha/);
})) passed++; else failed++;
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
run();
-132
View File
@@ -1,132 +0,0 @@
/**
* Tests for scripts/hooks/block-no-verify.js via run-with-flags.js
*/
const assert = require('assert');
const path = require('path');
const { spawnSync } = require('child_process');
const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js');
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (error) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runHook(input, env = {}) {
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
const result = spawnSync('node', [runner, 'pre:bash:block-no-verify', 'scripts/hooks/block-no-verify.js', 'minimal,standard,strict'], {
input: rawInput,
encoding: 'utf8',
env: {
...process.env,
ECC_HOOK_PROFILE: 'standard',
...env
},
timeout: 15000,
stdio: ['pipe', 'pipe', 'pipe']
});
return {
code: Number.isInteger(result.status) ? result.status : 1,
stdout: result.stdout || '',
stderr: result.stderr || ''
};
}
let passed = 0;
let failed = 0;
console.log('\nblock-no-verify hook tests');
console.log('─'.repeat(50));
// --- Basic allow/block ---
if (test('allows plain git commit', () => {
const r = runHook({ tool_input: { command: 'git commit -m "hello"' } });
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
if (test('blocks --no-verify on git commit', () => {
const r = runHook({ tool_input: { command: 'git commit --no-verify -m "msg"' } });
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
assert.ok(r.stderr.includes('BLOCKED'), `stderr should contain BLOCKED: ${r.stderr}`);
})) passed++; else failed++;
if (test('blocks -n shorthand on git commit', () => {
const r = runHook({ tool_input: { command: 'git commit -n -m "msg"' } });
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
assert.ok(r.stderr.includes('BLOCKED'), `stderr should contain BLOCKED: ${r.stderr}`);
})) passed++; else failed++;
if (test('blocks core.hooksPath override', () => {
const r = runHook({ tool_input: { command: 'git -c core.hooksPath=/dev/null commit -m "msg"' } });
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
assert.ok(r.stderr.includes('core.hooksPath'), `stderr should mention core.hooksPath: ${r.stderr}`);
})) passed++; else failed++;
// --- Chained command false positive prevention (Comment 2) ---
if (test('does not false-positive on -n belonging to git log in a chain', () => {
const r = runHook({ tool_input: { command: 'git log -n 10 && git commit -m "msg"' } });
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
if (test('does not false-positive on --no-verify in a prior non-git command', () => {
const r = runHook({ tool_input: { command: 'echo --no-verify && git commit -m "msg"' } });
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
if (test('still blocks --no-verify on the git commit part of a chain', () => {
const r = runHook({ tool_input: { command: 'git log -n 5 && git commit --no-verify -m "msg"' } });
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
})) passed++; else failed++;
// --- Subcommand detection (Comment 4) ---
if (test('does not misclassify "commit" as subcommand when it is an argument to push', () => {
// "git push origin commit" — "commit" is a refspec arg, not the subcommand
const r = runHook({ tool_input: { command: 'git push origin commit' } });
// This should detect "push" as the subcommand, not "commit"
// Either way it should not block since there's no --no-verify
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
// --- Blocks on push --no-verify ---
if (test('blocks --no-verify on git push', () => {
const r = runHook({ tool_input: { command: 'git push --no-verify' } });
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
assert.ok(r.stderr.includes('git push'), `stderr should mention git push: ${r.stderr}`);
})) passed++; else failed++;
// --- Non-git commands pass through ---
if (test('allows non-git commands', () => {
const r = runHook({ tool_input: { command: 'npm test' } });
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
// --- Plain text input (not JSON) ---
if (test('handles plain text input', () => {
const r = runHook('git commit -m "hello"');
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
if (test('blocks plain text input with --no-verify', () => {
const r = runHook('git commit --no-verify -m "msg"');
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
})) passed++; else failed++;
console.log('─'.repeat(50));
console.log(`Passed: ${passed} Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
-21
View File
@@ -131,27 +131,6 @@ function runTests() {
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
// 6. Prefers ECC_SESSION_ID for ECC2 session correlation
(test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID when both are present', () => {
const tmpHome = makeTempDir();
const input = {
model: 'claude-sonnet-4-20250514',
usage: { input_tokens: 120, output_tokens: 30 },
};
const result = runScript(input, {
...withTempHome(tmpHome),
ECC_SESSION_ID: 'ecc-session-1234',
CLAUDE_SESSION_ID: 'claude-session-9999',
});
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.strictEqual(row.session_id, 'ecc-session-1234', 'Expected ECC_SESSION_ID to win');
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
@@ -1,412 +0,0 @@
/**
* Tests for session-activity-tracker.js hook.
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const script = path.join(
__dirname,
'..',
'..',
'scripts',
'hooks',
'session-activity-tracker.js'
);
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (err) {
console.log(`${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
function makeTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-test-'));
}
function withTempHome(homeDir) {
return {
HOME: homeDir,
USERPROFILE: homeDir,
};
}
function runScript(input, envOverrides = {}, options = {}) {
const inputStr = typeof input === 'string' ? input : JSON.stringify(input);
const result = spawnSync('node', [script], {
encoding: 'utf8',
input: inputStr,
timeout: 10000,
env: { ...process.env, ...envOverrides },
cwd: options.cwd,
});
return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' };
}
function runTests() {
console.log('\n=== Testing session-activity-tracker.js ===\n');
let passed = 0;
let failed = 0;
(test('passes through input on stdout', () => {
const input = {
tool_name: 'Read',
tool_input: { file_path: 'README.md' },
tool_output: { output: 'ok' },
};
const inputStr = JSON.stringify(input);
const result = runScript(input, {
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'sess-123',
});
assert.strictEqual(result.code, 0);
assert.strictEqual(result.stdout, inputStr);
}) ? passed++ : failed++);
(test('creates tool activity metrics rows with file paths', () => {
const tmpHome = makeTempDir();
const input = {
tool_name: 'Write',
tool_input: {
file_path: 'src/app.rs',
},
tool_output: { output: 'wrote src/app.rs' },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'ecc-session-1234',
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
assert.ok(fs.existsSync(metricsFile), `Expected metrics file at ${metricsFile}`);
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.strictEqual(row.session_id, 'ecc-session-1234');
assert.strictEqual(row.tool_name, 'Write');
assert.strictEqual(row.input_params_json, '{"file_path":"src/app.rs"}');
assert.deepStrictEqual(row.file_paths, ['src/app.rs']);
assert.deepStrictEqual(row.file_events, [{ path: 'src/app.rs', action: 'create' }]);
assert.ok(row.id, 'Expected stable event id');
assert.ok(row.timestamp, 'Expected timestamp');
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('captures typed move file events from source/destination inputs', () => {
const tmpHome = makeTempDir();
const input = {
tool_name: 'Move',
tool_input: {
source_path: 'src/old.rs',
destination_path: 'src/new.rs',
},
tool_output: { output: 'moved file' },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'ecc-session-5678',
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.deepStrictEqual(row.file_paths, ['src/old.rs', 'src/new.rs']);
assert.deepStrictEqual(row.file_events, [
{ path: 'src/old.rs', action: 'move' },
{ path: 'src/new.rs', action: 'move' },
]);
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('captures replacement diff previews for edit tool input', () => {
const tmpHome = makeTempDir();
const input = {
tool_name: 'Edit',
tool_input: {
file_path: 'src/config.ts',
old_string: 'API_URL=http://localhost:3000',
new_string: 'API_URL=https://api.example.com',
},
tool_output: { output: 'updated config' },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'ecc-session-edit',
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.deepStrictEqual(row.file_events, [
{
path: 'src/config.ts',
action: 'modify',
diff_preview: 'API_URL=http://localhost:3000 -> API_URL=https://api.example.com',
patch_preview: '@@\n- API_URL=http://localhost:3000\n+ API_URL=https://api.example.com',
},
]);
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('captures MultiEdit nested edits with typed diff previews', () => {
const tmpHome = makeTempDir();
const input = {
tool_name: 'MultiEdit',
tool_input: {
edits: [
{
file_path: 'src/a.ts',
old_string: 'const a = 1;',
new_string: 'const a = 2;',
},
{
file_path: 'src/b.ts',
old_string: 'old name',
new_string: 'new name',
},
],
},
tool_output: { output: 'updated two files' },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'ecc-session-multiedit',
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.deepStrictEqual(row.file_paths, ['src/a.ts', 'src/b.ts']);
assert.deepStrictEqual(row.file_events, [
{
path: 'src/a.ts',
action: 'modify',
diff_preview: 'const a = 1; -> const a = 2;',
patch_preview: '@@\n- const a = 1;\n+ const a = 2;',
},
{
path: 'src/b.ts',
action: 'modify',
diff_preview: 'old name -> new name',
patch_preview: '@@\n- old name\n+ new name',
},
]);
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('reclassifies tracked Write activity as modify using git diff context', () => {
const tmpHome = makeTempDir();
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-repo-'));
spawnSync('git', ['init'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['config', 'user.email', 'ecc@example.com'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['config', 'user.name', 'ECC Tests'], { cwd: repoDir, encoding: 'utf8' });
const srcDir = path.join(repoDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
const trackedFile = path.join(srcDir, 'app.ts');
fs.writeFileSync(trackedFile, 'const count = 1;\n', 'utf8');
spawnSync('git', ['add', 'src/app.ts'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, encoding: 'utf8' });
fs.writeFileSync(trackedFile, 'const count = 2;\n', 'utf8');
const input = {
tool_name: 'Write',
tool_input: {
file_path: 'src/app.ts',
content: 'const count = 2;\n',
},
tool_output: { output: 'updated src/app.ts' },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'ecc-session-write-modify',
}, {
cwd: repoDir,
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.deepStrictEqual(row.file_events, [
{
path: 'src/app.ts',
action: 'modify',
diff_preview: 'const count = 1; -> const count = 2;',
patch_preview: '@@ -1 +1 @@\n-const count = 1;\n+const count = 2;',
},
]);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(repoDir, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('captures tracked Delete activity using git diff context', () => {
const tmpHome = makeTempDir();
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-delete-repo-'));
spawnSync('git', ['init'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['config', 'user.email', 'ecc@example.com'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['config', 'user.name', 'ECC Tests'], { cwd: repoDir, encoding: 'utf8' });
const srcDir = path.join(repoDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
const trackedFile = path.join(srcDir, 'obsolete.ts');
fs.writeFileSync(trackedFile, 'export const obsolete = true;\n', 'utf8');
spawnSync('git', ['add', 'src/obsolete.ts'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, encoding: 'utf8' });
fs.rmSync(trackedFile, { force: true });
const input = {
tool_name: 'Delete',
tool_input: {
file_path: 'src/obsolete.ts',
},
tool_output: { output: 'deleted src/obsolete.ts' },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'ecc-session-delete',
}, {
cwd: repoDir,
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.deepStrictEqual(row.file_events, [
{
path: 'src/obsolete.ts',
action: 'delete',
diff_preview: 'export const obsolete = true; ->',
patch_preview: '@@ -1 +0,0 @@\n-export const obsolete = true;',
},
]);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(repoDir, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('resolves repo-relative paths even when the hook runs from a nested cwd', () => {
const tmpHome = makeTempDir();
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-nested-repo-'));
spawnSync('git', ['init'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['config', 'user.email', 'ecc@example.com'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['config', 'user.name', 'ECC Tests'], { cwd: repoDir, encoding: 'utf8' });
const srcDir = path.join(repoDir, 'src');
const nestedCwd = path.join(repoDir, 'subdir');
fs.mkdirSync(srcDir, { recursive: true });
fs.mkdirSync(nestedCwd, { recursive: true });
const trackedFile = path.join(srcDir, 'app.ts');
fs.writeFileSync(trackedFile, 'const count = 1;\n', 'utf8');
spawnSync('git', ['add', 'src/app.ts'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, encoding: 'utf8' });
fs.writeFileSync(trackedFile, 'const count = 2;\n', 'utf8');
const input = {
tool_name: 'Write',
tool_input: {
file_path: 'src/app.ts',
content: 'const count = 2;\n',
},
tool_output: { output: 'updated src/app.ts' },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'ecc-session-nested-cwd',
}, {
cwd: nestedCwd,
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.deepStrictEqual(row.file_events, [
{
path: 'src/app.ts',
action: 'modify',
diff_preview: 'const count = 1; -> const count = 2;',
patch_preview: '@@ -1 +1 @@\n-const count = 1;\n+const count = 2;',
},
]);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(repoDir, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID and redacts bash summaries', () => {
const tmpHome = makeTempDir();
const input = {
tool_name: 'Bash',
tool_input: {
command: 'curl --token abc123 -H "Authorization: Bearer topsecret" https://example.com',
},
tool_output: { output: 'done' },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'ecc-session-1',
CLAUDE_SESSION_ID: 'claude-session-2',
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.strictEqual(row.session_id, 'ecc-session-1');
assert.ok(row.input_summary.includes('<REDACTED>'));
assert.ok(!row.input_summary.includes('abc123'));
assert.ok(!row.input_summary.includes('topsecret'));
assert.ok(row.input_params_json.includes('<REDACTED>'));
assert.ok(!row.input_params_json.includes('abc123'));
assert.ok(!row.input_params_json.includes('topsecret'));
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('handles invalid JSON gracefully', () => {
const tmpHome = makeTempDir();
const invalidInput = 'not valid json {{{';
const result = runScript(invalidInput, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'sess-123',
});
assert.strictEqual(result.code, 0);
assert.strictEqual(result.stdout, invalidInput);
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();
-48
View File
@@ -1,48 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const { INLINE_RESOLVE } = require('../../scripts/lib/resolve-ecc-root');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`PASS ${name}`);
passed += 1;
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error.stack || error.message || String(error));
failed += 1;
}
}
const sessionsDoc = fs.readFileSync(path.join(__dirname, '..', '..', 'commands', 'sessions.md'), 'utf8');
const skillHealthDoc = fs.readFileSync(path.join(__dirname, '..', '..', 'commands', 'skill-health.md'), 'utf8');
test('sessions command uses shared inline resolver in all node scripts', () => {
assert.strictEqual((sessionsDoc.match(/const _r = /g) || []).length, 6);
assert.strictEqual((sessionsDoc.match(/\['marketplace','ecc'\]/g) || []).length, 6);
assert.strictEqual((sessionsDoc.match(/\['marketplace','everything-claude-code'\]/g) || []).length, 6);
assert.strictEqual((sessionsDoc.match(/\['ecc','everything-claude-code'\]/g) || []).length, 6);
});
test('skill-health command uses shared inline resolver in all shell snippets', () => {
assert.strictEqual((skillHealthDoc.match(/var r=/g) || []).length, 3);
assert.strictEqual((skillHealthDoc.match(/\['marketplace','ecc'\]/g) || []).length, 3);
assert.strictEqual((skillHealthDoc.match(/\['marketplace','everything-claude-code'\]/g) || []).length, 3);
assert.strictEqual((skillHealthDoc.match(/\['ecc','everything-claude-code'\]/g) || []).length, 3);
});
test('inline resolver covers current and legacy marketplace plugin roots', () => {
assert.ok(INLINE_RESOLVE.includes('"marketplace","ecc"'));
assert.ok(INLINE_RESOLVE.includes('"marketplace","everything-claude-code"'));
});
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+4 -7
View File
@@ -6,9 +6,6 @@ const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const CURRENT_PACKAGE_VERSION = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')
).version;
const {
createInstallState,
@@ -69,7 +66,7 @@ function runTests() {
},
],
source: {
repoVersion: CURRENT_PACKAGE_VERSION,
repoVersion: '1.10.0',
repoCommit: 'abc123',
manifestVersion: 1,
},
@@ -103,7 +100,7 @@ function runTests() {
},
operations: [],
source: {
repoVersion: CURRENT_PACKAGE_VERSION,
repoVersion: '1.10.0',
repoCommit: 'abc123',
manifestVersion: 1,
},
@@ -157,7 +154,7 @@ function runTests() {
},
operations: [operation],
source: {
repoVersion: CURRENT_PACKAGE_VERSION,
repoVersion: '1.10.0',
repoCommit: 'abc123',
manifestVersion: 1,
},
@@ -211,7 +208,7 @@ function runTests() {
skippedModules: [],
},
source: {
repoVersion: CURRENT_PACKAGE_VERSION,
repoVersion: '1.10.0',
repoCommit: 'abc123',
manifestVersion: 1,
},
+5 -8
View File
@@ -13,9 +13,6 @@ const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const CURRENT_PACKAGE_VERSION = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')
).version;
const { resolveEccRoot, INLINE_RESOLVE } = require('../../scripts/lib/resolve-ecc-root');
@@ -184,7 +181,7 @@ function runTests() {
const homeDir = createTempDir();
try {
const expected = setupLegacyPluginInstall(homeDir, ['marketplace', 'ecc']);
setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
const result = resolveEccRoot({ envRoot: '', homeDir });
assert.strictEqual(result, expected);
} finally {
@@ -196,7 +193,7 @@ function runTests() {
if (test('discovers plugin root from cache directory', () => {
const homeDir = createTempDir();
try {
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
const result = resolveEccRoot({ envRoot: '', homeDir });
assert.strictEqual(result, expected);
} finally {
@@ -208,7 +205,7 @@ function runTests() {
const homeDir = createTempDir();
try {
const claudeDir = setupStandardInstall(homeDir);
setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
const result = resolveEccRoot({ envRoot: '', homeDir });
assert.strictEqual(result, claudeDir,
'Standard install should take precedence over plugin cache');
@@ -221,7 +218,7 @@ function runTests() {
const homeDir = createTempDir();
try {
setupPluginCache(homeDir, 'everything-claude-code', 'legacy-org', '1.7.0');
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
const result = resolveEccRoot({ envRoot: '', homeDir });
// Should find one of them (either is valid)
assert.ok(
@@ -314,7 +311,7 @@ function runTests() {
if (test('INLINE_RESOLVE discovers plugin cache when env var is unset', () => {
const homeDir = createTempDir();
try {
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
const { execFileSync } = require('child_process');
const result = execFileSync('node', [
'-e', `console.log(${INLINE_RESOLVE})`,
+5 -141
View File
@@ -20,20 +20,6 @@ const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const repoRootWithSep = `${repoRoot}${path.sep}`;
const packageJsonPath = path.join(repoRoot, 'package.json');
const packageLockPath = path.join(repoRoot, 'package-lock.json');
const rootAgentsPath = path.join(repoRoot, 'AGENTS.md');
const trAgentsPath = path.join(repoRoot, 'docs', 'tr', 'AGENTS.md');
const zhCnAgentsPath = path.join(repoRoot, 'docs', 'zh-CN', 'AGENTS.md');
const ptBrReadmePath = path.join(repoRoot, 'docs', 'pt-BR', 'README.md');
const trReadmePath = path.join(repoRoot, 'docs', 'tr', 'README.md');
const rootZhCnReadmePath = path.join(repoRoot, 'README.zh-CN.md');
const agentYamlPath = path.join(repoRoot, 'agent.yaml');
const versionFilePath = path.join(repoRoot, 'VERSION');
const zhCnReadmePath = path.join(repoRoot, 'docs', 'zh-CN', 'README.md');
const selectiveInstallArchitecturePath = path.join(repoRoot, 'docs', 'SELECTIVE-INSTALL-ARCHITECTURE.md');
const opencodePackageJsonPath = path.join(repoRoot, '.opencode', 'package.json');
const opencodePackageLockPath = path.join(repoRoot, '.opencode', 'package-lock.json');
let passed = 0;
let failed = 0;
@@ -78,86 +64,6 @@ function assertSafeRepoRelativePath(relativePath, label) {
);
}
const rootPackage = loadJsonObject(packageJsonPath, 'package.json');
const packageLock = loadJsonObject(packageLockPath, 'package-lock.json');
const opencodePackageLock = loadJsonObject(opencodePackageLockPath, '.opencode/package-lock.json');
const expectedVersion = rootPackage.version;
test('package.json has version field', () => {
assert.ok(expectedVersion, 'Expected package.json version field');
});
test('package-lock.json root version matches package.json', () => {
assert.strictEqual(packageLock.version, expectedVersion);
assert.ok(packageLock.packages && packageLock.packages[''], 'Expected package-lock root package entry');
assert.strictEqual(packageLock.packages[''].version, expectedVersion);
});
test('AGENTS.md version line matches package.json', () => {
const agentsSource = fs.readFileSync(rootAgentsPath, 'utf8');
const match = agentsSource.match(/^\*\*Version:\*\* ([0-9]+\.[0-9]+\.[0-9]+)$/m);
assert.ok(match, 'Expected AGENTS.md to declare a top-level version line');
assert.strictEqual(match[1], expectedVersion);
});
test('docs/tr/AGENTS.md version line matches package.json', () => {
const agentsSource = fs.readFileSync(trAgentsPath, 'utf8');
const match = agentsSource.match(/^\*\*Sürüm:\*\* ([0-9]+\.[0-9]+\.[0-9]+)$/m);
assert.ok(match, 'Expected docs/tr/AGENTS.md to declare a top-level version line');
assert.strictEqual(match[1], expectedVersion);
});
test('docs/zh-CN/AGENTS.md version line matches package.json', () => {
const agentsSource = fs.readFileSync(zhCnAgentsPath, 'utf8');
const match = agentsSource.match(/^\*\*版本:\*\* ([0-9]+\.[0-9]+\.[0-9]+)$/m);
assert.ok(match, 'Expected docs/zh-CN/AGENTS.md to declare a top-level version line');
assert.strictEqual(match[1], expectedVersion);
});
test('agent.yaml version matches package.json', () => {
const agentYamlSource = fs.readFileSync(agentYamlPath, 'utf8');
const match = agentYamlSource.match(/^version:\s*([0-9]+\.[0-9]+\.[0-9]+)$/m);
assert.ok(match, 'Expected agent.yaml to declare a top-level version field');
assert.strictEqual(match[1], expectedVersion);
});
test('VERSION file matches package.json', () => {
const versionFile = fs.readFileSync(versionFilePath, 'utf8').trim();
assert.ok(versionFile, 'Expected VERSION file to be non-empty');
assert.strictEqual(versionFile, expectedVersion);
});
test('docs/SELECTIVE-INSTALL-ARCHITECTURE.md repoVersion example matches package.json', () => {
const source = fs.readFileSync(selectiveInstallArchitecturePath, 'utf8');
const match = source.match(/"repoVersion":\s*"([0-9]+\.[0-9]+\.[0-9]+)"/);
assert.ok(match, 'Expected docs/SELECTIVE-INSTALL-ARCHITECTURE.md to declare a repoVersion example');
assert.strictEqual(match[1], expectedVersion);
});
test('docs/pt-BR/README.md latest release heading matches package.json', () => {
const source = fs.readFileSync(ptBrReadmePath, 'utf8');
assert.ok(
source.includes(`### v${expectedVersion} `),
'Expected docs/pt-BR/README.md to advertise the current release heading',
);
});
test('docs/tr/README.md latest release heading matches package.json', () => {
const source = fs.readFileSync(trReadmePath, 'utf8');
assert.ok(
source.includes(`### v${expectedVersion} `),
'Expected docs/tr/README.md to advertise the current release heading',
);
});
test('README.zh-CN.md latest release heading matches package.json', () => {
const source = fs.readFileSync(rootZhCnReadmePath, 'utf8');
assert.ok(
source.includes(`### v${expectedVersion} `),
'Expected README.zh-CN.md to advertise the current release heading',
);
});
// ── Claude plugin manifest ────────────────────────────────────────────────────
console.log('\n=== .claude-plugin/plugin.json ===\n');
@@ -174,12 +80,8 @@ test('claude plugin.json has version field', () => {
assert.ok(claudePlugin.version, 'Expected version field');
});
test('claude plugin.json version matches package.json', () => {
assert.strictEqual(claudePlugin.version, expectedVersion);
});
test('claude plugin.json uses published plugin name', () => {
assert.strictEqual(claudePlugin.name, 'everything-claude-code');
test('claude plugin.json uses short plugin slug', () => {
assert.strictEqual(claudePlugin.name, 'ecc');
});
test('claude plugin.json agents is an array', () => {
@@ -248,14 +150,10 @@ test('claude marketplace.json keeps only Claude-supported top-level keys', () =>
}
});
test('claude marketplace.json has plugins array with the published plugin entry', () => {
test('claude marketplace.json has plugins array with a short ecc plugin entry', () => {
assert.ok(Array.isArray(claudeMarketplace.plugins) && claudeMarketplace.plugins.length > 0, 'Expected plugins array');
assert.strictEqual(claudeMarketplace.name, 'everything-claude-code');
assert.strictEqual(claudeMarketplace.plugins[0].name, 'everything-claude-code');
});
test('claude marketplace.json plugin version matches package.json', () => {
assert.strictEqual(claudeMarketplace.plugins[0].version, expectedVersion);
assert.strictEqual(claudeMarketplace.name, 'ecc');
assert.strictEqual(claudeMarketplace.plugins[0].name, 'ecc');
});
// ── Codex plugin manifest ─────────────────────────────────────────────────────
@@ -285,10 +183,6 @@ test('codex plugin.json has version field', () => {
assert.ok(codexPlugin.version, 'Expected version field');
});
test('codex plugin.json version matches package.json', () => {
assert.strictEqual(codexPlugin.version, expectedVersion);
});
test('codex plugin.json skills is a string (not array) per official spec', () => {
assert.strictEqual(
typeof codexPlugin.skills,
@@ -374,7 +268,6 @@ test('marketplace.json exists at .agents/plugins/', () => {
});
const marketplace = loadJsonObject(marketplacePath, '.agents/plugins/marketplace.json');
const opencodePackage = loadJsonObject(opencodePackageJsonPath, '.opencode/package.json');
test('marketplace.json has name field', () => {
assert.ok(marketplace.name, 'Expected name field');
@@ -391,7 +284,6 @@ test('marketplace.json has plugins array with at least one entry', () => {
test('marketplace.json plugin entries have required fields', () => {
for (const plugin of marketplace.plugins) {
assert.ok(plugin.name, `Plugin entry missing name`);
assert.ok(plugin.version, `Plugin "${plugin.name}" missing version`);
assert.ok(plugin.source && plugin.source.source, `Plugin "${plugin.name}" missing source.source`);
assert.ok(plugin.policy && plugin.policy.installation, `Plugin "${plugin.name}" missing policy.installation`);
assert.ok(plugin.category, `Plugin "${plugin.name}" missing category`);
@@ -402,10 +294,6 @@ test('marketplace.json plugin entry uses short plugin slug', () => {
assert.strictEqual(marketplace.plugins[0].name, 'ecc');
});
test('marketplace.json plugin version matches package.json', () => {
assert.strictEqual(marketplace.plugins[0].version, expectedVersion);
});
test('marketplace local plugin path resolves to the repo-root Codex bundle', () => {
for (const plugin of marketplace.plugins) {
if (!plugin.source || plugin.source.source !== 'local') {
@@ -429,30 +317,6 @@ test('marketplace local plugin path resolves to the repo-root Codex bundle', ()
}
});
test('.opencode/package.json version matches package.json', () => {
assert.strictEqual(opencodePackage.version, expectedVersion);
});
test('.opencode/package-lock.json root version matches package.json', () => {
assert.strictEqual(opencodePackageLock.version, expectedVersion);
assert.ok(opencodePackageLock.packages && opencodePackageLock.packages[''], 'Expected .opencode/package-lock root package entry');
assert.strictEqual(opencodePackageLock.packages[''].version, expectedVersion);
});
test('README version row matches package.json', () => {
const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
const match = readme.match(/^\| \*\*Version\*\* \| Plugin \| Plugin \| Reference config \| ([0-9][0-9.]*) \|$/m);
assert.ok(match, 'Expected README version summary row');
assert.strictEqual(match[1], expectedVersion);
});
test('docs/zh-CN/README.md version row matches package.json', () => {
const readme = fs.readFileSync(zhCnReadmePath, 'utf8');
const match = readme.match(/^\| \*\*版本\*\* \| 插件 \| 插件 \| 参考配置 \| ([0-9][0-9.]*) \|$/m);
assert.ok(match, 'Expected docs/zh-CN/README.md version summary row');
assert.strictEqual(match[1], expectedVersion);
});
// ── Summary ───────────────────────────────────────────────────────────────────
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
-395
View File
@@ -1,395 +0,0 @@
/**
* Tests for scripts/auto-update.js
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
parseArgs,
deriveRepoRootFromState,
buildInstallApplyArgs,
determineInstallCwd,
runAutoUpdate,
} = require('../../scripts/auto-update');
const {
createInstallState,
} = require('../../scripts/lib/install-state');
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (error) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function cleanup(dirPath) {
fs.rmSync(dirPath, { recursive: true, force: true });
}
function makeRecord({ repoRoot, homeDir, projectRoot, adapter, request, resolution, operations }) {
const targetRoot = adapter.kind === 'project'
? path.join(projectRoot, `.${adapter.target}`)
: path.join(homeDir, '.claude');
const installStatePath = adapter.kind === 'project'
? path.join(targetRoot, 'ecc-install-state.json')
: path.join(targetRoot, 'ecc', 'install-state.json');
const state = createInstallState({
adapter,
targetRoot,
installStatePath,
request,
resolution,
operations,
source: {
repoVersion: '1.10.0',
repoCommit: 'abc123',
manifestVersion: 1,
},
});
return {
adapter,
targetRoot,
installStatePath,
exists: true,
state,
error: null,
repoRoot,
};
}
function ensureFakeRepo(repoRoot) {
fs.mkdirSync(path.join(repoRoot, 'scripts'), { recursive: true });
fs.writeFileSync(
path.join(repoRoot, 'package.json'),
JSON.stringify({ name: 'everything-claude-code', version: '1.10.0' }, null, 2)
);
fs.writeFileSync(path.join(repoRoot, 'scripts', 'install-apply.js'), '#!/usr/bin/env node\n');
}
function runTests() {
console.log('\n=== Testing auto-update.js ===\n');
let passed = 0;
let failed = 0;
if (test('parseArgs reads repo-root, target, dry-run, and json flags', () => {
const parsed = parseArgs([
'node',
'scripts/auto-update.js',
'--target',
'cursor',
'--repo-root',
'/tmp/ecc',
'--dry-run',
'--json',
]);
assert.deepStrictEqual(parsed.targets, ['cursor']);
assert.strictEqual(parsed.repoRoot, '/tmp/ecc');
assert.strictEqual(parsed.dryRun, true);
assert.strictEqual(parsed.json, true);
})) passed += 1; else failed += 1;
if (test('parseArgs rejects unknown arguments', () => {
assert.throws(
() => parseArgs(['node', 'scripts/auto-update.js', '--bogus']),
/Unknown argument: --bogus/
);
})) passed += 1; else failed += 1;
if (test('deriveRepoRootFromState uses sourcePath and sourceRelativePath', () => {
const state = {
operations: [
{
sourcePath: path.join('/tmp', 'ecc', 'scripts', 'setup-package-manager.js'),
sourceRelativePath: path.join('scripts', 'setup-package-manager.js'),
},
],
};
assert.strictEqual(
deriveRepoRootFromState(state),
path.resolve(path.join('/tmp', 'ecc'))
);
})) passed += 1; else failed += 1;
if (test('deriveRepoRootFromState fails when source metadata is unavailable', () => {
assert.throws(
() => deriveRepoRootFromState({ operations: [{ destinationPath: '/tmp/file' }] }),
/Unable to infer ECC repo root/
);
})) passed += 1; else failed += 1;
if (test('buildInstallApplyArgs reconstructs legacy installs', () => {
const record = {
adapter: { target: 'claude', kind: 'home' },
state: {
target: { target: 'claude' },
request: {
profile: null,
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: ['typescript', 'python'],
legacyMode: true,
},
},
};
assert.deepStrictEqual(buildInstallApplyArgs(record), [
'--target', 'claude',
'typescript',
'python',
]);
})) passed += 1; else failed += 1;
if (test('buildInstallApplyArgs reconstructs manifest installs', () => {
const record = {
adapter: { target: 'cursor', kind: 'project' },
state: {
target: { target: 'cursor' },
request: {
profile: 'developer',
modules: ['platform-configs'],
includeComponents: ['component:alpha'],
excludeComponents: ['component:beta'],
legacyLanguages: [],
legacyMode: false,
},
},
};
assert.deepStrictEqual(buildInstallApplyArgs(record), [
'--target', 'cursor',
'--profile', 'developer',
'--modules', 'platform-configs',
'--with', 'component:alpha',
'--without', 'component:beta',
]);
})) passed += 1; else failed += 1;
if (test('determineInstallCwd uses the project root for project installs', () => {
const record = {
adapter: { kind: 'project' },
state: {
target: {
root: path.join('/tmp', 'project', '.cursor'),
},
},
};
assert.strictEqual(determineInstallCwd(record, '/tmp/ecc'), path.join('/tmp', 'project'));
})) passed += 1; else failed += 1;
if (test('runAutoUpdate reports when no install-state files are present', () => {
const result = runAutoUpdate(
{
homeDir: '/tmp/home',
projectRoot: '/tmp/project',
dryRun: true,
},
{
discoverInstalledStates: () => [],
}
);
assert.strictEqual(result.results.length, 0);
assert.strictEqual(result.summary.checkedCount, 0);
assert.strictEqual(result.summary.errorCount, 0);
})) passed += 1; else failed += 1;
if (test('runAutoUpdate rejects mixed inferred repo roots', () => {
const homeDir = createTempDir('auto-update-home-');
const projectRoot = createTempDir('auto-update-project-');
const repoOne = createTempDir('auto-update-repo-');
const repoTwo = createTempDir('auto-update-repo-');
try {
ensureFakeRepo(repoOne);
ensureFakeRepo(repoTwo);
const records = [
makeRecord({
repoRoot: repoOne,
homeDir,
projectRoot,
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
request: {
profile: null,
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: ['typescript'],
legacyMode: true,
},
resolution: { selectedModules: ['legacy-claude-rules'], skippedModules: [] },
operations: [
{
kind: 'copy-file',
moduleId: 'legacy-claude-rules',
sourcePath: path.join(repoOne, 'rules', 'common', 'coding-style.md'),
sourceRelativePath: path.join('rules', 'common', 'coding-style.md'),
destinationPath: path.join(homeDir, '.claude', 'rules', 'common', 'coding-style.md'),
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
},
],
}),
makeRecord({
repoRoot: repoTwo,
homeDir,
projectRoot,
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
request: {
profile: 'core',
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: false,
},
resolution: { selectedModules: ['rules-core'], skippedModules: [] },
operations: [
{
kind: 'copy-file',
moduleId: 'rules-core',
sourcePath: path.join(repoTwo, '.cursor', 'mcp.json'),
sourceRelativePath: path.join('.cursor', 'mcp.json'),
destinationPath: path.join(projectRoot, '.cursor', 'mcp.json'),
strategy: 'sync-root-children',
ownership: 'managed',
scaffoldOnly: false,
},
],
}),
];
assert.throws(
() => runAutoUpdate(
{
homeDir,
projectRoot,
dryRun: true,
},
{
discoverInstalledStates: () => records,
}
),
/Multiple ECC repo roots detected/
);
} finally {
cleanup(homeDir);
cleanup(projectRoot);
cleanup(repoOne);
cleanup(repoTwo);
}
})) passed += 1; else failed += 1;
if (test('runAutoUpdate fetches, pulls, and reinstalls using reconstructed args', () => {
const homeDir = createTempDir('auto-update-home-');
const projectRoot = createTempDir('auto-update-project-');
const repoRoot = createTempDir('auto-update-repo-');
try {
ensureFakeRepo(repoRoot);
const records = [
makeRecord({
repoRoot,
homeDir,
projectRoot,
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
request: {
profile: 'developer',
modules: [],
includeComponents: ['component:alpha'],
excludeComponents: ['component:beta'],
legacyLanguages: [],
legacyMode: false,
},
resolution: { selectedModules: ['rules-core'], skippedModules: [] },
operations: [
{
kind: 'copy-file',
moduleId: 'platform-configs',
sourcePath: path.join(repoRoot, '.cursor', 'mcp.json'),
sourceRelativePath: path.join('.cursor', 'mcp.json'),
destinationPath: path.join(projectRoot, '.cursor', 'mcp.json'),
strategy: 'sync-root-children',
ownership: 'managed',
scaffoldOnly: false,
},
],
}),
];
const commands = [];
const result = runAutoUpdate(
{
homeDir,
projectRoot,
dryRun: false,
},
{
discoverInstalledStates: () => records,
runExternalCommand: (command, args, options) => {
commands.push({ command, args, options });
if (command === process.execPath) {
return {
stdout: JSON.stringify({
dryRun: false,
result: {
installStatePath: path.join(projectRoot, '.cursor', 'ecc-install-state.json'),
},
}),
stderr: '',
};
}
return { stdout: '', stderr: '' };
},
}
);
assert.strictEqual(result.summary.checkedCount, 1);
assert.strictEqual(result.summary.updatedCount, 1);
assert.deepStrictEqual(commands.map(entry => [entry.command, entry.args[0]]), [
['git', 'fetch'],
['git', 'pull'],
[process.execPath, path.join(repoRoot, 'scripts', 'install-apply.js')],
]);
assert.deepStrictEqual(commands[2].args.slice(1), [
'--target', 'cursor',
'--profile', 'developer',
'--with', 'component:alpha',
'--without', 'component:beta',
'--json',
]);
assert.strictEqual(commands[2].options.cwd, projectRoot);
} finally {
cleanup(homeDir);
cleanup(projectRoot);
cleanup(repoRoot);
}
})) passed += 1; else failed += 1;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();
+1 -38
View File
@@ -49,9 +49,8 @@ function main() {
const result = spawnSync("npm", ["pack", "--dry-run", "--json"], {
cwd: repoRoot,
encoding: "utf8",
shell: process.platform === "win32",
})
assert.strictEqual(result.status, 0, result.error?.message || result.stderr)
assert.strictEqual(result.status, 0, result.stderr)
const packOutput = JSON.parse(result.stdout)
const packagedPaths = new Set(packOutput[0]?.files?.map((file) => file.path) ?? [])
@@ -68,42 +67,6 @@ function main() {
packagedPaths.has(".opencode/dist/tools/index.js"),
"npm pack should include compiled OpenCode tool output"
)
assert.ok(
packagedPaths.has(".claude-plugin/marketplace.json"),
"npm pack should include .claude-plugin/marketplace.json"
)
assert.ok(
packagedPaths.has(".claude-plugin/plugin.json"),
"npm pack should include .claude-plugin/plugin.json"
)
assert.ok(
packagedPaths.has(".codex-plugin/plugin.json"),
"npm pack should include .codex-plugin/plugin.json"
)
assert.ok(
packagedPaths.has(".agents/plugins/marketplace.json"),
"npm pack should include .agents/plugins/marketplace.json"
)
assert.ok(
packagedPaths.has(".opencode/package.json"),
"npm pack should include .opencode/package.json"
)
assert.ok(
packagedPaths.has(".opencode/package-lock.json"),
"npm pack should include .opencode/package-lock.json"
)
assert.ok(
packagedPaths.has("agent.yaml"),
"npm pack should include agent.yaml"
)
assert.ok(
packagedPaths.has("AGENTS.md"),
"npm pack should include AGENTS.md"
)
assert.ok(
packagedPaths.has("VERSION"),
"npm pack should include VERSION"
)
}],
]
-17
View File
@@ -68,7 +68,6 @@ function main() {
assert.match(result.stdout, /catalog/);
assert.match(result.stdout, /list-installed/);
assert.match(result.stdout, /doctor/);
assert.match(result.stdout, /auto-update/);
}],
['delegates explicit install command', () => {
const result = runCli(['install', '--dry-run', '--json', 'typescript']);
@@ -113,17 +112,6 @@ function main() {
const payload = parseJson(result.stdout);
assert.deepStrictEqual(payload.records, []);
}],
['delegates auto-update command', () => {
const homeDir = createTempDir('ecc-cli-home-');
const projectRoot = createTempDir('ecc-cli-project-');
const result = runCli(['auto-update', '--dry-run', '--json'], {
cwd: projectRoot,
env: { HOME: homeDir },
});
assert.strictEqual(result.status, 0, result.stderr);
const payload = parseJson(result.stdout);
assert.deepStrictEqual(payload.results, []);
}],
['delegates session-inspect command', () => {
const homeDir = createTempDir('ecc-cli-home-');
const sessionsDir = path.join(homeDir, '.claude', 'sessions');
@@ -147,11 +135,6 @@ function main() {
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, /Usage: node scripts\/repair\.js/);
}],
['supports help for the auto-update subcommand', () => {
const result = runCli(['help', 'auto-update']);
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, /Usage: node scripts\/auto-update\.js/);
}],
['supports help for the catalog subcommand', () => {
const result = runCli(['help', 'catalog']);
assert.strictEqual(result.status, 0, result.stderr);
+110 -95
View File
@@ -23,6 +23,8 @@ function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
const REPO_ROOT = path.join(__dirname, '..', '..');
function run(args = [], options = {}) {
const homeDir = options.homeDir || process.env.HOME;
const env = {
@@ -329,7 +331,7 @@ function runTests() {
assert.ok(result.stderr.includes('Unknown install module: ghost-module'));
})) passed++; else failed++;
if (test('installs claude hooks without generating settings.json', () => {
if (test('merges hooks into settings.json for claude target install', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
@@ -339,7 +341,15 @@ function runTests() {
const claudeRoot = path.join(homeDir, '.claude');
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should be copied');
assert.ok(!fs.existsSync(path.join(claudeRoot, 'settings.json')), 'settings.json should not be created just to install managed hooks');
const settingsPath = path.join(claudeRoot, 'settings.json');
assert.ok(fs.existsSync(settingsPath), 'settings.json should exist after install');
const settings = readJson(settingsPath);
assert.ok(settings.hooks, 'settings.json should contain hooks key');
assert.ok(settings.hooks.PreToolUse, 'hooks should include PreToolUse');
assert.ok(Array.isArray(settings.hooks.PreToolUse), 'PreToolUse should be an array');
assert.ok(settings.hooks.PreToolUse.length > 0, 'PreToolUse should have entries');
} finally {
cleanup(homeDir);
cleanup(projectDir);
@@ -355,11 +365,23 @@ function runTests() {
assert.strictEqual(result.code, 0, result.stderr);
const claudeRoot = path.join(homeDir, '.claude');
const settings = readJson(path.join(claudeRoot, 'settings.json'));
const installedHooks = readJson(path.join(claudeRoot, 'hooks', 'hooks.json'));
const normSep = (s) => s.replace(/\\/g, '/');
const expectedFragment = normSep(path.join(claudeRoot, 'scripts', 'hooks', 'auto-tmux-dev.js'));
const autoTmuxEntry = settings.hooks.PreToolUse.find(entry => entry.id === 'pre:bash:auto-tmux-dev');
assert.ok(autoTmuxEntry, 'settings.json should include the auto tmux hook');
assert.ok(
normSep(autoTmuxEntry.hooks[0].command).includes(expectedFragment),
'settings.json should use the installed Claude root for hook commands'
);
assert.ok(
!autoTmuxEntry.hooks[0].command.includes('${CLAUDE_PLUGIN_ROOT}'),
'settings.json should not retain CLAUDE_PLUGIN_ROOT placeholders after install'
);
const installedAutoTmuxEntry = installedHooks.hooks.PreToolUse.find(entry => entry.id === 'pre:bash:auto-tmux-dev');
assert.ok(installedAutoTmuxEntry, 'hooks/hooks.json should include the auto tmux hook');
assert.ok(
@@ -376,7 +398,7 @@ function runTests() {
}
})) passed++; else failed++;
if (test('preserves existing settings.json without mutating it during claude install', () => {
if (test('preserves existing settings fields and hook entries when merging hooks', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
@@ -401,15 +423,17 @@ function runTests() {
const settings = readJson(path.join(claudeRoot, 'settings.json'));
assert.strictEqual(settings.effortLevel, 'high', 'existing effortLevel should be preserved');
assert.deepStrictEqual(settings.env, { MY_VAR: '1' }, 'existing env should be preserved');
assert.ok(settings.hooks, 'hooks should be merged in');
assert.ok(settings.hooks.PreToolUse, 'PreToolUse hooks should exist');
assert.ok(
settings.hooks.PreToolUse.some(entry => JSON.stringify(entry).includes('echo custom-pretool')),
'existing PreToolUse entries should be preserved'
);
assert.ok(settings.hooks.PreToolUse.length > 1, 'ECC PreToolUse hooks should be appended');
assert.deepStrictEqual(
settings.hooks.UserPromptSubmit,
[{ matcher: '*', hooks: [{ type: 'command', command: 'echo custom-submit' }] }],
'existing hooks should be left untouched'
);
assert.deepStrictEqual(
settings.hooks.PreToolUse,
[{ matcher: 'Write', hooks: [{ type: 'command', command: 'echo custom-pretool' }] }],
'managed Claude hooks should not be injected into settings.json'
'user-defined hook event types should be preserved'
);
} finally {
cleanup(homeDir);
@@ -491,7 +515,7 @@ function runTests() {
}
})) passed++; else failed++;
if (test('reinstall does not create settings.json when only managed hooks are installed', () => {
if (test('reinstall does not duplicate managed hook entries', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
@@ -499,43 +523,69 @@ function runTests() {
const firstInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
assert.strictEqual(firstInstall.code, 0, firstInstall.stderr);
const secondInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
assert.strictEqual(secondInstall.code, 0, secondInstall.stderr);
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'settings.json')));
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('reinstall leaves pre-existing hook-based settings.json untouched', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
try {
const claudeRoot = path.join(homeDir, '.claude');
fs.mkdirSync(claudeRoot, { recursive: true });
const settingsPath = path.join(claudeRoot, 'settings.json');
const legacySettings = {
hooks: {
PreToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'echo legacy-pretool' }] }],
},
};
fs.writeFileSync(settingsPath, JSON.stringify(legacySettings, null, 2));
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
const afterFirstInstall = readJson(settingsPath);
const preToolUseLength = afterFirstInstall.hooks.PreToolUse.length;
const secondInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
assert.strictEqual(secondInstall.code, 0, secondInstall.stderr);
const afterSecondInstall = readJson(settingsPath);
assert.deepStrictEqual(afterSecondInstall, legacySettings);
assert.strictEqual(
afterSecondInstall.hooks.PreToolUse.length,
preToolUseLength,
'managed hook entries should not duplicate on reinstall'
);
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('ignores malformed existing settings.json during claude install', () => {
if (test('reinstall deduplicates legacy hooks without ids against new managed ids', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
try {
const firstInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
assert.strictEqual(firstInstall.code, 0, firstInstall.stderr);
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
const afterFirstInstall = readJson(settingsPath);
const legacySettings = JSON.parse(JSON.stringify(afterFirstInstall));
for (const entries of Object.values(legacySettings.hooks)) {
if (!Array.isArray(entries)) {
continue;
}
for (const entry of entries) {
delete entry.id;
}
}
fs.writeFileSync(settingsPath, JSON.stringify(legacySettings, null, 2));
const legacyPreToolUseLength = legacySettings.hooks.PreToolUse.length;
const secondInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
assert.strictEqual(secondInstall.code, 0, secondInstall.stderr);
const afterSecondInstall = readJson(settingsPath);
assert.strictEqual(
afterSecondInstall.hooks.PreToolUse.length,
legacyPreToolUseLength,
'legacy hook installs should not duplicate when ids are introduced'
);
assert.ok(
afterSecondInstall.hooks.PreToolUse.every(entry => entry && typeof entry === 'object'),
'merged hook entries should remain valid objects'
);
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('fails when existing settings.json is malformed', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
@@ -546,17 +596,18 @@ function runTests() {
fs.writeFileSync(settingsPath, '{ invalid json\n');
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
assert.strictEqual(result.code, 0, result.stderr);
assert.strictEqual(result.code, 1);
assert.ok(result.stderr.includes('Failed to parse existing settings at'));
assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '{ invalid json\n');
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should still be copied');
assert.ok(fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should still be written');
assert.ok(!fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied on validation failure');
assert.ok(!fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should not be written on validation failure');
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('ignores non-object existing settings.json during claude install', () => {
if (test('fails when existing settings.json root is not an object', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
@@ -567,10 +618,12 @@ function runTests() {
fs.writeFileSync(settingsPath, '[]\n');
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
assert.strictEqual(result.code, 0, result.stderr);
assert.strictEqual(result.code, 1);
assert.ok(result.stderr.includes('Invalid existing settings at'));
assert.ok(result.stderr.includes('expected a JSON object'));
assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '[]\n');
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should still be copied');
assert.ok(fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should still be written');
assert.ok(!fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied on validation failure');
assert.ok(!fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should not be written on validation failure');
} finally {
cleanup(homeDir);
cleanup(projectDir);
@@ -578,64 +631,26 @@ function runTests() {
})) passed++; else failed++;
if (test('fails when source hooks.json root is not an object before copying files', () => {
const tempDir = createTempDir('install-apply-invalid-hooks-');
const targetRoot = path.join(tempDir, '.claude');
const installStatePath = path.join(targetRoot, 'ecc', 'install-state.json');
const sourceHooksPath = path.join(tempDir, 'hooks.json');
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
const sourceHooksPath = path.join(REPO_ROOT, 'hooks', 'hooks.json');
const originalHooks = fs.readFileSync(sourceHooksPath, 'utf8');
try {
fs.writeFileSync(sourceHooksPath, '[]\n');
assert.throws(() => {
applyInstallPlan({
targetRoot,
installStatePath,
statePreview: {
schemaVersion: 'ecc.install.v1',
installedAt: new Date().toISOString(),
target: {
id: 'claude-home',
kind: 'home',
root: targetRoot,
installStatePath,
},
request: {
profile: 'core',
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: false,
},
resolution: {
selectedModules: ['hooks-runtime'],
skippedModules: [],
},
source: {
repoVersion: null,
repoCommit: null,
manifestVersion: 1,
},
operations: [],
},
adapter: { target: 'claude' },
operations: [{
kind: 'copy-file',
moduleId: 'hooks-runtime',
sourcePath: sourceHooksPath,
sourceRelativePath: 'hooks/hooks.json',
destinationPath: path.join(targetRoot, 'hooks', 'hooks.json'),
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
}],
});
}, /Invalid hooks config at .*expected a JSON object/);
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
assert.strictEqual(result.code, 1);
assert.ok(result.stderr.includes('Invalid hooks config at'));
assert.ok(result.stderr.includes('expected a JSON object'));
assert.ok(!fs.existsSync(path.join(targetRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied when source hooks are invalid');
assert.ok(!fs.existsSync(installStatePath), 'install state should not be written when source hooks are invalid');
const claudeRoot = path.join(homeDir, '.claude');
assert.ok(!fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied when source hooks are invalid');
assert.ok(!fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should not be written when source hooks are invalid');
} finally {
cleanup(tempDir);
fs.writeFileSync(sourceHooksPath, originalHooks);
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
-71
View File
@@ -1,71 +0,0 @@
/**
* Source-level tests for scripts/release.sh
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'release.sh');
const source = fs.readFileSync(scriptPath, 'utf8');
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing release.sh ===\n');
let passed = 0;
let failed = 0;
if (test('release script rejects untracked files when checking cleanliness', () => {
assert.ok(
source.includes('git status --porcelain --untracked-files=all'),
'release.sh should use git status --porcelain --untracked-files=all for cleanliness checks'
);
})) passed++; else failed++;
if (test('release script reruns release metadata sync validation before commit/tag', () => {
const syncCheckIndex = source.lastIndexOf('node tests/plugin-manifest.test.js');
const commitIndex = source.indexOf('git commit -m "chore: bump plugin version to $VERSION"');
assert.ok(syncCheckIndex >= 0, 'release.sh should run plugin-manifest.test.js');
assert.ok(commitIndex >= 0, 'release.sh should create the release commit');
assert.ok(
syncCheckIndex < commitIndex,
'plugin-manifest.test.js should run before the release commit is created'
);
})) passed++; else failed++;
if (test('release script verifies npm pack payload after version updates and before commit/tag', () => {
const updateIndex = source.indexOf('update_version "$ROOT_PACKAGE_JSON"');
const packCheckIndex = source.indexOf('node tests/scripts/build-opencode.test.js');
const commitIndex = source.indexOf('git commit -m "chore: bump plugin version to $VERSION"');
assert.ok(updateIndex >= 0, 'release.sh should update package version fields');
assert.ok(packCheckIndex >= 0, 'release.sh should run build-opencode.test.js');
assert.ok(commitIndex >= 0, 'release.sh should create the release commit');
assert.ok(
updateIndex < packCheckIndex,
'build-opencode.test.js should run after versioned files are updated'
);
assert.ok(
packCheckIndex < commitIndex,
'build-opencode.test.js should run before the release commit is created'
);
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();