mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-13 03:33:15 +08:00
Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2e8e9d17e | |||
| a36d85e807 | |||
| f1249d3915 | |||
| 057cfe3203 | |||
| 0f1106c21b | |||
| 6db6d84d03 | |||
| 00fb1dc1bd | |||
| a9219c7000 | |||
| ee7ea98e1a | |||
| 0233203415 | |||
| d2deb04489 | |||
| 9d849711f1 | |||
| 93135d7707 | |||
| 00c2b38533 | |||
| c95fd4cf06 | |||
| 1b3c4a53ba | |||
| 61dfd6fa92 | |||
| 6fd1358d04 | |||
| 125d5e6199 | |||
| 4ff5a7169f | |||
| cee82417db | |||
| f4b1b11e10 | |||
| e7dd7047b5 | |||
| b6426ade32 | |||
| 790cb0205c | |||
| 046af44065 | |||
| d36e9c48a4 | |||
| 0f028f38f6 | |||
| feee17ad02 | |||
| 7b7ec434df | |||
| 176efb7623 | |||
| b51792fe0e | |||
| 050d9a9707 | |||
| 03e52f49e8 | |||
| 30913b2cc4 | |||
| 7809518612 | |||
| bbed46d3eb | |||
| 4a1f3cbd3f | |||
| bcd869d520 | |||
| 2e6eeafabd | |||
| 52371f5016 | |||
| d84c64fa0e | |||
| a4aaa30e93 | |||
| 97afd95451 | |||
| 29ff44e23e | |||
| 9c525009d7 | |||
| 9c294f7815 | |||
| 766bf31737 | |||
| 9523575721 | |||
| 406722b5ef | |||
| 5258a75382 | |||
| 966af37f89 | |||
| 22a5a8de6d | |||
| d3b680b6db | |||
| d49ceacb7d | |||
| 8cc92c59a6 | |||
| 77c9082deb | |||
| 727d9380cb | |||
| 7a13564a8b | |||
| 23348a21a6 | |||
| 0b68af123c | |||
| 4b1ff48219 | |||
| beaba1ca15 | |||
| 315b87d391 | |||
| 4adb3324ef | |||
| 08f0e86d76 | |||
| 8653d6d5d5 | |||
| 194bf605c2 | |||
| 1e4d6a4161 | |||
| e48468a9e7 | |||
| ea0fb3c0fc | |||
| b48a52f9a0 | |||
| 913c00c74d | |||
| 8936d09951 | |||
| 599a9d1e7b | |||
| 5fb2e62216 | |||
| b45a6ca810 | |||
| a4d0a4fc14 | |||
| 491ee81889 | |||
| 75c2503abd | |||
| e2b24e43a2 | |||
| d0dbb20805 | |||
| cf8b5473c7 | |||
| 181bc26b29 | |||
| 0513898b9d | |||
| 2048f0d6f5 | |||
| f5437078e1 | |||
| 13f99cbf1c | |||
| 491f213fbd | |||
| 941d4e6172 | |||
| b01a300c31 | |||
| f28f55c41e | |||
| 31f672275e | |||
| eee9768cd8 | |||
| c395b42d2c | |||
| edd027edd4 | |||
| a0f69cec92 | |||
| 24a3ffa234 | |||
| 48fd68115e | |||
| 6f08e78456 | |||
| 67d06687a0 | |||
| 95c33d3c04 | |||
| 08f61f667d | |||
| cf9c68846c | |||
| a54799127c | |||
| c6e26ddea4 | |||
| f136a4e0d6 | |||
| 3c16c85a75 | |||
| 0c509fe57e | |||
| 996edff6d1 | |||
| f2cfaee6fe | |||
| dc36a636af | |||
| 6fc3f7c3f4 | |||
| f29e70883c | |||
| e50c97c29b | |||
| 7e3bb3aec2 | |||
| 92c9d1f2c9 | |||
| 669d9cc790 | |||
| 1c27f7b29a | |||
| cc5fe121bf | |||
| 15e05d96ad | |||
| bab03bd8af | |||
| 1755069df2 | |||
| 3b700c8715 | |||
| 077f46b777 | |||
| 8fc40da739 | |||
| 8440181001 | |||
| c7bf143450 | |||
| 63299b15b3 | |||
| 3eb9bc8ef5 |
@@ -6,6 +6,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "ecc",
|
||||
"version": "1.10.0",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "../.."
|
||||
|
||||
@@ -38,18 +38,21 @@ jobs:
|
||||
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
- name: Verify plugin.json version matches tag
|
||||
- name: Verify package version matches tag
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
TAG_VERSION="${TAG_NAME#v}"
|
||||
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)"
|
||||
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
|
||||
id: highlights
|
||||
env:
|
||||
|
||||
@@ -47,6 +47,21 @@ 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 }}
|
||||
|
||||
@@ -78,6 +78,17 @@
|
||||
|
||||
---
|
||||
|
||||
## 最新动态
|
||||
|
||||
### v1.10.0 — 表面同步、运营工作流与 ECC 2.0 Alpha(2026年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 分钟内快速上手:
|
||||
|
||||
@@ -183,6 +183,21 @@ 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:
|
||||
|
||||
@@ -82,6 +82,8 @@ 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.
|
||||
|
||||
@@ -80,6 +80,15 @@ 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.
|
||||
|
||||
@@ -79,6 +79,15 @@ 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.
|
||||
|
||||
Generated
+168
@@ -2,6 +2,12 @@
|
||||
# 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"
|
||||
@@ -300,6 +306,26 @@ 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"
|
||||
@@ -492,19 +518,23 @@ 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",
|
||||
]
|
||||
|
||||
@@ -590,6 +620,16 @@ 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"
|
||||
@@ -1139,6 +1179,16 @@ 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"
|
||||
@@ -1610,6 +1660,20 @@ 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"
|
||||
@@ -1659,6 +1723,41 @@ 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"
|
||||
@@ -1792,6 +1891,12 @@ 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"
|
||||
@@ -1853,6 +1958,12 @@ 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"
|
||||
@@ -2206,6 +2317,30 @@ 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"
|
||||
@@ -2372,6 +2507,24 @@ 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"
|
||||
@@ -2525,6 +2678,15 @@ 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"
|
||||
@@ -2774,6 +2936,12 @@ 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"
|
||||
|
||||
@@ -25,6 +25,9 @@ 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"] }
|
||||
@@ -40,6 +43,7 @@ libc = "0.2"
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
cron = "0.12"
|
||||
|
||||
# UUID for session IDs
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
+72
-2
@@ -1,13 +1,41 @@
|
||||
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 },
|
||||
TaskHandoff {
|
||||
task: String,
|
||||
context: String,
|
||||
#[serde(default)]
|
||||
priority: TaskPriority,
|
||||
},
|
||||
/// Agent requesting information from another
|
||||
Query { question: String },
|
||||
/// Response to a query
|
||||
@@ -46,7 +74,16 @@ pub fn parse(content: &str) -> Option<MessageType> {
|
||||
pub fn preview(msg_type: &str, content: &str) -> String {
|
||||
match parse(content) {
|
||||
Some(MessageType::TaskHandoff { task, .. }) => {
|
||||
format!("handoff {}", truncate(&task, 56))
|
||||
let priority = handoff_priority(content);
|
||||
if priority == TaskPriority::Normal {
|
||||
format!("handoff {}", truncate(&task, 56))
|
||||
} else {
|
||||
format!(
|
||||
"handoff [{}] {}",
|
||||
priority_label(priority),
|
||||
truncate(&task, 48)
|
||||
)
|
||||
}
|
||||
}
|
||||
Some(MessageType::Query { question }) => {
|
||||
format!("query {}", truncate(&question, 56))
|
||||
@@ -75,6 +112,39 @@ 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 {
|
||||
|
||||
+1611
-10
File diff suppressed because it is too large
Load Diff
+9882
-61
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,635 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,9 @@ 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,
|
||||
}
|
||||
@@ -47,7 +49,9 @@ impl ToolCallEvent {
|
||||
.score,
|
||||
tool_name,
|
||||
input_summary,
|
||||
input_params_json: "{}".to_string(),
|
||||
output_summary: output_summary.into(),
|
||||
trigger_summary: String::new(),
|
||||
duration_ms,
|
||||
}
|
||||
}
|
||||
@@ -238,7 +242,9 @@ 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,
|
||||
@@ -268,7 +274,9 @@ 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,
|
||||
×tamp,
|
||||
@@ -306,6 +314,8 @@ 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,
|
||||
@@ -313,6 +323,7 @@ mod tests {
|
||||
worktree: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_heartbeat_at: now,
|
||||
metrics: SessionMetrics::default(),
|
||||
}
|
||||
}
|
||||
@@ -397,6 +408,8 @@ 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);
|
||||
|
||||
+62
-25
@@ -22,13 +22,19 @@ 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, timeout) {
|
||||
if let Err(e) = check_sessions(&db, &cfg) {
|
||||
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}");
|
||||
}
|
||||
@@ -37,10 +43,14 @@ 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).await {
|
||||
if let Err(e) = maybe_auto_prune_inactive_worktrees(&db, &cfg).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;
|
||||
}
|
||||
}
|
||||
@@ -82,28 +92,38 @@ where
|
||||
Ok(failed_sessions)
|
||||
}
|
||||
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
|
||||
fn check_sessions(db: &StateStore, cfg: &Config) -> Result<()> {
|
||||
let _ = manager::enforce_session_heartbeats(db, cfg)?;
|
||||
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,
|
||||
@@ -408,9 +428,9 @@ where
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
async fn maybe_auto_prune_inactive_worktrees(db: &StateStore) -> Result<usize> {
|
||||
async fn maybe_auto_prune_inactive_worktrees(db: &StateStore, cfg: &Config) -> Result<usize> {
|
||||
maybe_auto_prune_inactive_worktrees_with_recorder(
|
||||
|| manager::prune_inactive_worktrees(db),
|
||||
|| manager::prune_inactive_worktrees(db, cfg),
|
||||
|pruned, active| db.record_daemon_auto_prune_pass(pruned, active),
|
||||
)
|
||||
.await
|
||||
@@ -436,6 +456,7 @@ 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 {
|
||||
@@ -444,6 +465,9 @@ 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)
|
||||
}
|
||||
@@ -491,6 +515,8 @@ 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,
|
||||
@@ -498,6 +524,7 @@ mod tests {
|
||||
worktree: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_heartbeat_at: now,
|
||||
metrics: SessionMetrics::default(),
|
||||
}
|
||||
}
|
||||
@@ -1210,9 +1237,11 @@ 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(),
|
||||
})
|
||||
}
|
||||
@@ -1247,9 +1276,16 @@ 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(),
|
||||
})
|
||||
})
|
||||
@@ -1269,6 +1305,7 @@ 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| {
|
||||
|
||||
+4872
-67
File diff suppressed because it is too large
Load Diff
+849
-21
@@ -6,13 +6,307 @@ 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,
|
||||
@@ -20,6 +314,7 @@ 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,
|
||||
}
|
||||
|
||||
@@ -28,6 +323,7 @@ pub enum SessionState {
|
||||
Pending,
|
||||
Running,
|
||||
Idle,
|
||||
Stale,
|
||||
Completed,
|
||||
Failed,
|
||||
Stopped,
|
||||
@@ -39,6 +335,7 @@ 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"),
|
||||
@@ -60,12 +357,21 @@ 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
|
||||
@@ -78,6 +384,7 @@ impl SessionState {
|
||||
match value {
|
||||
"running" => SessionState::Running,
|
||||
"idle" => SessionState::Idle,
|
||||
"stale" => SessionState::Stale,
|
||||
"completed" => SessionState::Completed,
|
||||
"failed" => SessionState::Failed,
|
||||
"stopped" => SessionState::Stopped,
|
||||
@@ -95,6 +402,8 @@ 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,
|
||||
@@ -102,27 +411,6 @@ 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,
|
||||
@@ -133,3 +421,543 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,31 @@ 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)]
|
||||
@@ -70,10 +95,7 @@ impl SessionOutputStore {
|
||||
}
|
||||
|
||||
pub fn push_line(&self, session_id: &str, stream: OutputStream, text: impl Into<String>) {
|
||||
let line = OutputLine {
|
||||
stream,
|
||||
text: text.into(),
|
||||
};
|
||||
let line = OutputLine::with_current_timestamp(stream, text);
|
||||
|
||||
{
|
||||
let mut buffers = self.lock_buffers();
|
||||
@@ -145,5 +167,6 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -26,6 +27,9 @@ enum DbMessage {
|
||||
line: String,
|
||||
ack: oneshot::Sender<DbAck>,
|
||||
},
|
||||
TouchHeartbeat {
|
||||
ack: oneshot::Sender<DbAck>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -53,6 +57,10 @@ 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,
|
||||
@@ -111,6 +119,17 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +139,7 @@ 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());
|
||||
|
||||
@@ -152,6 +172,19 @@ 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(),
|
||||
@@ -169,6 +202,8 @@ pub async fn capture_command_output(
|
||||
));
|
||||
|
||||
let status = child.wait().await?;
|
||||
heartbeat_task.abort();
|
||||
let _ = heartbeat_task.await;
|
||||
stdout_task.await??;
|
||||
stderr_task.await??;
|
||||
|
||||
@@ -237,6 +272,8 @@ 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,
|
||||
@@ -244,6 +281,7 @@ mod tests {
|
||||
worktree: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_heartbeat_at: now,
|
||||
metrics: SessionMetrics::default(),
|
||||
})?;
|
||||
|
||||
@@ -254,9 +292,14 @@ 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)
|
||||
.await?;
|
||||
let status = capture_command_output(
|
||||
db_path.clone(),
|
||||
session_id.clone(),
|
||||
command,
|
||||
output_store,
|
||||
std::time::Duration::from_millis(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(status.success());
|
||||
|
||||
@@ -286,4 +329,51 @@ 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(())
|
||||
}
|
||||
}
|
||||
|
||||
+4837
-891
File diff suppressed because it is too large
Load Diff
@@ -27,9 +27,49 @@ 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('=')) => {
|
||||
@@ -38,17 +78,53 @@ 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(),
|
||||
|
||||
+10639
-869
File diff suppressed because it is too large
Load Diff
+134
-33
@@ -1,30 +1,49 @@
|
||||
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,
|
||||
Warning,
|
||||
Alert50,
|
||||
Alert75,
|
||||
Alert90,
|
||||
OverBudget,
|
||||
}
|
||||
|
||||
impl BudgetState {
|
||||
pub(crate) const fn is_warning(self) -> bool {
|
||||
matches!(self, Self::Warning | Self::OverBudget)
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn badge(self) -> Option<&'static str> {
|
||||
pub(crate) fn summary_suffix(self, thresholds: BudgetAlertThresholds) -> Option<String> {
|
||||
match self {
|
||||
Self::Warning => Some("warning"),
|
||||
Self::OverBudget => Some("over budget"),
|
||||
Self::Unconfigured => Some("no budget"),
|
||||
Self::Normal => None,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,11 +51,13 @@ impl BudgetState {
|
||||
let base = Style::default().fg(match self {
|
||||
Self::Unconfigured => Color::DarkGray,
|
||||
Self::Normal => Color::DarkGray,
|
||||
Self::Warning => Color::Yellow,
|
||||
Self::Alert50 => Color::Cyan,
|
||||
Self::Alert75 => Color::Yellow,
|
||||
Self::Alert90 => Color::LightRed,
|
||||
Self::OverBudget => Color::Red,
|
||||
});
|
||||
|
||||
if self.is_warning() {
|
||||
if matches!(self, Self::Alert75 | Self::Alert90 | Self::OverBudget) {
|
||||
base.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
base
|
||||
@@ -55,30 +76,43 @@ 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) -> Self {
|
||||
pub(crate) fn tokens(
|
||||
title: &'a str,
|
||||
used: u64,
|
||||
budget: u64,
|
||||
thresholds: BudgetAlertThresholds,
|
||||
) -> 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) -> Self {
|
||||
pub(crate) fn currency(
|
||||
title: &'a str,
|
||||
used: f64,
|
||||
budget: f64,
|
||||
thresholds: BudgetAlertThresholds,
|
||||
) -> Self {
|
||||
Self {
|
||||
title,
|
||||
used,
|
||||
budget,
|
||||
thresholds,
|
||||
format: MeterFormat::Currency,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn state(&self) -> BudgetState {
|
||||
budget_state(self.used, self.budget)
|
||||
budget_state(self.used, self.budget, self.thresholds)
|
||||
}
|
||||
|
||||
fn ratio(&self) -> f64 {
|
||||
@@ -97,7 +131,7 @@ impl<'a> TokenMeter<'a> {
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)];
|
||||
|
||||
if let Some(badge) = self.state().badge() {
|
||||
if let Some(badge) = self.state().badge(self.thresholds) {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(format!("[{badge}]"), self.state().style()));
|
||||
}
|
||||
@@ -165,7 +199,7 @@ impl Widget for TokenMeter<'_> {
|
||||
.label(self.display_label())
|
||||
.gauge_style(
|
||||
Style::default()
|
||||
.fg(gradient_color(self.ratio()))
|
||||
.fg(gradient_color(self.ratio(), self.thresholds))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.style(Style::default().fg(Color::DarkGray))
|
||||
@@ -182,35 +216,51 @@ pub(crate) fn budget_ratio(used: f64, budget: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn budget_state(used: f64, budget: f64) -> BudgetState {
|
||||
pub(crate) fn budget_state(
|
||||
used: f64,
|
||||
budget: f64,
|
||||
thresholds: BudgetAlertThresholds,
|
||||
) -> BudgetState {
|
||||
if budget <= 0.0 {
|
||||
BudgetState::Unconfigured
|
||||
} else if used / budget >= 1.0 {
|
||||
BudgetState::OverBudget
|
||||
} else if used / budget >= WARNING_THRESHOLD {
|
||||
BudgetState::Warning
|
||||
} 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 {
|
||||
BudgetState::Normal
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gradient_color(ratio: f64) -> Color {
|
||||
pub(crate) fn gradient_color(ratio: f64, thresholds: BudgetAlertThresholds) -> 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 <= WARNING_THRESHOLD {
|
||||
interpolate_rgb(GREEN, YELLOW, clamped / WARNING_THRESHOLD)
|
||||
if clamped <= thresholds.warning {
|
||||
interpolate_rgb(
|
||||
GREEN,
|
||||
YELLOW,
|
||||
clamped / thresholds.warning.max(f64::EPSILON),
|
||||
)
|
||||
} else {
|
||||
interpolate_rgb(
|
||||
YELLOW,
|
||||
RED,
|
||||
(clamped - WARNING_THRESHOLD) / (1.0 - WARNING_THRESHOLD),
|
||||
(clamped - thresholds.warning) / (1.0 - thresholds.warning),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn threshold_label(value: f64) -> String {
|
||||
format!("{}%", (value * 100.0).round() as u64)
|
||||
}
|
||||
|
||||
pub(crate) fn format_currency(value: f64) -> String {
|
||||
format!("${value:.2}")
|
||||
}
|
||||
@@ -246,25 +296,76 @@ 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 super::{gradient_color, BudgetState, TokenMeter};
|
||||
use crate::config::{BudgetAlertThresholds, Config};
|
||||
|
||||
use super::{gradient_color, threshold_label, BudgetState, TokenMeter};
|
||||
|
||||
#[test]
|
||||
fn warning_state_starts_at_eighty_percent() {
|
||||
let meter = TokenMeter::tokens("Token Budget", 80, 100);
|
||||
|
||||
assert_eq!(meter.state(), BudgetState::Warning);
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_runs_from_green_to_yellow_to_red() {
|
||||
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));
|
||||
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%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_meter_renders_compact_usage_label() {
|
||||
let meter = TokenMeter::tokens("Token Budget", 4_000, 10_000);
|
||||
let meter = TokenMeter::tokens(
|
||||
"Token Budget",
|
||||
4_000,
|
||||
10_000,
|
||||
Config::BUDGET_ALERT_THRESHOLDS,
|
||||
);
|
||||
let area = Rect::new(0, 0, 48, 2);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
|
||||
|
||||
+1882
-53
File diff suppressed because it is too large
Load Diff
@@ -260,6 +260,18 @@
|
||||
"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": [
|
||||
|
||||
+5
-1
@@ -50,6 +50,7 @@
|
||||
".opencode/index.ts",
|
||||
".opencode/opencode.json",
|
||||
".opencode/package.json",
|
||||
".opencode/package-lock.json",
|
||||
".opencode/tsconfig.json",
|
||||
".opencode/MIGRATION.md",
|
||||
".opencode/README.md",
|
||||
@@ -90,14 +91,17 @@
|
||||
"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"
|
||||
"llms.txt",
|
||||
"VERSION"
|
||||
],
|
||||
"bin": {
|
||||
"ecc": "scripts/ecc.js",
|
||||
|
||||
@@ -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.CLAUDE_SESSION_ID || 'default');
|
||||
const sessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default');
|
||||
|
||||
const metricsDir = path.join(getClaudeDir(), 'metrics');
|
||||
ensureDir(metricsDir);
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
#!/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) {
|
||||
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) {
|
||||
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) {
|
||||
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,
|
||||
};
|
||||
+172
-11
@@ -6,9 +6,21 @@ 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() {
|
||||
@@ -36,14 +48,14 @@ if [[ "$CURRENT_BRANCH" != "main" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check working tree is clean
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
# Check working tree is clean, including untracked files
|
||||
if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; 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" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$OPENCODE_PACKAGE_JSON"; do
|
||||
for FILE in "$ROOT_PACKAGE_JSON" "$PACKAGE_LOCK_JSON" "$ROOT_AGENTS_MD" "$TR_AGENTS_MD" "$ZH_CN_AGENTS_MD" "$AGENT_YAML" "$VERSION_FILE" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$CODEX_MARKETPLACE_JSON" "$CODEX_PLUGIN_JSON" "$OPENCODE_PACKAGE_JSON" "$OPENCODE_PACKAGE_LOCK_JSON" "$README_FILE" "$ZH_CN_README_FILE" "$SELECTIVE_INSTALL_ARCHITECTURE_DOC"; do
|
||||
if [[ ! -f "$FILE" ]]; then
|
||||
echo "Error: $FILE not found"
|
||||
exit 1
|
||||
@@ -58,13 +70,6 @@ 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"
|
||||
@@ -75,14 +80,170 @@ 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" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$OPENCODE_PACKAGE_JSON"
|
||||
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 commit -m "chore: bump plugin version to $VERSION"
|
||||
git tag "v$VERSION"
|
||||
git push origin main "v$VERSION"
|
||||
|
||||
@@ -131,6 +131,27 @@ 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* 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();
|
||||
@@ -6,6 +6,9 @@ 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,
|
||||
@@ -66,7 +69,7 @@ function runTests() {
|
||||
},
|
||||
],
|
||||
source: {
|
||||
repoVersion: '1.10.0',
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
@@ -100,7 +103,7 @@ function runTests() {
|
||||
},
|
||||
operations: [],
|
||||
source: {
|
||||
repoVersion: '1.10.0',
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
@@ -154,7 +157,7 @@ function runTests() {
|
||||
},
|
||||
operations: [operation],
|
||||
source: {
|
||||
repoVersion: '1.10.0',
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
@@ -208,7 +211,7 @@ function runTests() {
|
||||
skippedModules: [],
|
||||
},
|
||||
source: {
|
||||
repoVersion: '1.10.0',
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: 1,
|
||||
},
|
||||
|
||||
@@ -13,6 +13,9 @@ 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');
|
||||
|
||||
@@ -181,7 +184,7 @@ function runTests() {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupLegacyPluginInstall(homeDir, ['marketplace', 'ecc']);
|
||||
setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
|
||||
setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
@@ -193,7 +196,7 @@ function runTests() {
|
||||
if (test('discovers plugin root from cache directory', () => {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
|
||||
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, expected);
|
||||
} finally {
|
||||
@@ -205,7 +208,7 @@ function runTests() {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
const claudeDir = setupStandardInstall(homeDir);
|
||||
setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
|
||||
setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
assert.strictEqual(result, claudeDir,
|
||||
'Standard install should take precedence over plugin cache');
|
||||
@@ -218,7 +221,7 @@ function runTests() {
|
||||
const homeDir = createTempDir();
|
||||
try {
|
||||
setupPluginCache(homeDir, 'everything-claude-code', 'legacy-org', '1.7.0');
|
||||
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', '1.10.0');
|
||||
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
|
||||
const result = resolveEccRoot({ envRoot: '', homeDir });
|
||||
// Should find one of them (either is valid)
|
||||
assert.ok(
|
||||
@@ -311,7 +314,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', '1.10.0');
|
||||
const expected = setupPluginCache(homeDir, 'ecc', 'affaan-m', CURRENT_PACKAGE_VERSION);
|
||||
const { execFileSync } = require('child_process');
|
||||
const result = execFileSync('node', [
|
||||
'-e', `console.log(${INLINE_RESOLVE})`,
|
||||
|
||||
@@ -20,6 +20,20 @@ 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;
|
||||
@@ -64,6 +78,86 @@ 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');
|
||||
|
||||
@@ -80,6 +174,10 @@ 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 short plugin slug', () => {
|
||||
assert.strictEqual(claudePlugin.name, 'ecc');
|
||||
});
|
||||
@@ -156,6 +254,10 @@ test('claude marketplace.json has plugins array with a short ecc plugin entry',
|
||||
assert.strictEqual(claudeMarketplace.plugins[0].name, 'ecc');
|
||||
});
|
||||
|
||||
test('claude marketplace.json plugin version matches package.json', () => {
|
||||
assert.strictEqual(claudeMarketplace.plugins[0].version, expectedVersion);
|
||||
});
|
||||
|
||||
// ── Codex plugin manifest ─────────────────────────────────────────────────────
|
||||
// Per official docs: https://platform.openai.com/docs/codex/plugins
|
||||
// - .codex-plugin/plugin.json is the required manifest
|
||||
@@ -183,6 +285,10 @@ 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,
|
||||
@@ -268,6 +374,7 @@ 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');
|
||||
@@ -284,6 +391,7 @@ 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`);
|
||||
@@ -294,6 +402,10 @@ 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') {
|
||||
@@ -317,6 +429,30 @@ 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}`);
|
||||
|
||||
@@ -49,8 +49,9 @@ function main() {
|
||||
const result = spawnSync("npm", ["pack", "--dry-run", "--json"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
assert.strictEqual(result.status, 0, result.stderr)
|
||||
assert.strictEqual(result.status, 0, result.error?.message || result.stderr)
|
||||
|
||||
const packOutput = JSON.parse(result.stdout)
|
||||
const packagedPaths = new Set(packOutput[0]?.files?.map((file) => file.path) ?? [])
|
||||
@@ -67,6 +68,42 @@ 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"
|
||||
)
|
||||
}],
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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();
|
||||
Reference in New Issue
Block a user