mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-13 19:51:24 +08:00
Compare commits
270 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f9467f826 | |||
| cf79534c2c | |||
| aec611a98b | |||
| 0f0efd7d7c | |||
| b5294fc89a | |||
| f579dad768 | |||
| 1a50145d39 | |||
| eb900ddd81 | |||
| ccecb0b9f4 | |||
| 9fb88c6700 | |||
| 6b7bd7156c | |||
| 1fabf4d2cf | |||
| 7eb7c598fb | |||
| 8b5c0c1b07 | |||
| c1e7a272cc | |||
| 5427c27930 | |||
| b5c4d2beb9 | |||
| 34380326c8 | |||
| 9227d3cc30 | |||
| 8da668f1ac | |||
| 1b7c5789fc | |||
| cdeb837838 | |||
| cca163c776 | |||
| c54b44edf3 | |||
| 2691cfc0f1 | |||
| b2c4b7f51c | |||
| c924290b5b | |||
| e46deb93c8 | |||
| 8776c4f8f3 | |||
| e5225db006 | |||
| 48a30b53c8 | |||
| 3be24a5704 | |||
| 76b6e22b4d | |||
| ecc5e0e2d6 | |||
| aa96279ecc | |||
| e0ddb331f6 | |||
| 85e331e49a | |||
| 5eedc8adb4 | |||
| c64cc69eb2 | |||
| 6c67566767 | |||
| deb3b1dc14 | |||
| 2e44beabc1 | |||
| e2b5353fec | |||
| 9ae51bc3c1 | |||
| 7f7e2c2c52 | |||
| 7a33b2b3c9 | |||
| 68ee51f1e3 | |||
| dd2962ee92 | |||
| 5c4570baa5 | |||
| 1a950e4f83 | |||
| 8cd6378c81 | |||
| ef7613c526 | |||
| a0a1eda8fc | |||
| bd207aabe1 | |||
| 4dbed5ff5b | |||
| 6eadf786f5 | |||
| 9e607ebb30 | |||
| 30f6ae4253 | |||
| c826305060 | |||
| db8247d701 | |||
| adb46a95a6 | |||
| 48e5a1fa75 | |||
| 2fb041c6de | |||
| 7374ef6a73 | |||
| bd2aec48ed | |||
| 6dc6b9266a | |||
| 5540282dcb | |||
| 67256194a0 | |||
| 5b0e123c10 | |||
| bb96fdc9dc | |||
| 6ed1c643e7 | |||
| 0fcb43ea90 | |||
| 133e881ce0 | |||
| 45823fcede | |||
| 18c90a7a17 | |||
| 9da8e5f6ac | |||
| 3792b69a38 | |||
| a2ad68e7e6 | |||
| 1b17c5c9d8 | |||
| 94e8f29d19 | |||
| de8a7dfef8 | |||
| 2b09308224 | |||
| 5f55484fa9 | |||
| e29da39eaf | |||
| f4c7aac5b8 | |||
| b749f5d772 | |||
| 2ece2cfc90 | |||
| 28edd197c2 | |||
| fc5921a521 | |||
| 809e0fa0a9 | |||
| dae663d856 | |||
| 6a247d4c43 | |||
| 92e5b4d415 | |||
| 9a64e0d271 | |||
| b6a290d061 | |||
| 96139b2dad | |||
| 8a2d13187c | |||
| 813755b879 | |||
| 74b91cb3f3 | |||
| d39a8a049a | |||
| 35aa02c645 | |||
| fd0cde69d8 | |||
| 0f6d06d779 | |||
| c277b19ee8 | |||
| 5736b3b684 | |||
| 6691e7cc9e | |||
| a7f73576a9 | |||
| 7fc44c91b8 | |||
| bc42a34e9a | |||
| f53a89ff88 | |||
| 626c18f4c7 | |||
| 2d044b8032 | |||
| 57de4129da | |||
| 5ae63b301f | |||
| 4b92288a27 | |||
| 45faeb90a7 | |||
| 51abaf0fc0 | |||
| 5a03922934 | |||
| 33673fb37a | |||
| 228be4f8b8 | |||
| 643d03575a | |||
| aa8948d5cf | |||
| 50dc4b0492 | |||
| 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 | |||
| 1b3ccb85aa | |||
| 2e5e94cb7f | |||
| adfe8a8311 | |||
| b3f781a648 | |||
| 86cbe3d616 | |||
| 9bd8e8b3c7 | |||
| e226772a72 | |||
| e363c54057 | |||
| eb274d25d9 | |||
| dada133784 | |||
| d8c8178f92 | |||
| 27d7964bb1 | |||
| e6460534e3 | |||
| 4834dfd280 | |||
| 7f2c14ecf8 | |||
| 027d77468e | |||
| 689235af16 | |||
| 4834b63b35 | |||
| 2dee4072a3 | |||
| e7be2ddf8d | |||
| 10b8471e3c | |||
| dd14888f5f | |||
| 87d520f0b1 | |||
| 5070b2d785 | |||
| afb97961e3 | |||
| dc12e902b1 | |||
| 2b7b717664 | |||
| d738089e3e | |||
| bcf8d0617e | |||
| da4c7791fe | |||
| 53d8cee6f8 | |||
| cd94878374 | |||
| 0ff58108e4 | |||
| 1bc9b9c585 | |||
| 10e34aa47a |
@@ -6,6 +6,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "ecc",
|
||||
"version": "1.10.0",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "../.."
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
{
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "ecc",
|
||||
"description": "Battle-tested Claude Code configurations from an Anthropic hackathon winner — agents, skills, hooks, rules, and legacy command shims evolved over 10+ months of intensive daily use",
|
||||
"name": "everything-claude-code",
|
||||
"owner": {
|
||||
"name": "Affaan Mustafa",
|
||||
"email": "me@affaanmustafa.com"
|
||||
@@ -11,7 +9,7 @@
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "ecc",
|
||||
"name": "everything-claude-code",
|
||||
"source": "./",
|
||||
"description": "The most comprehensive Claude Code plugin — 38 agents, 156 skills, 72 legacy command shims, selective install profiles, and production-ready hooks for TDD, security scanning, code review, and continuous learning",
|
||||
"version": "1.10.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "ecc",
|
||||
"name": "everything-claude-code",
|
||||
"version": "1.10.0",
|
||||
"description": "Battle-tested Claude Code plugin for engineering teams — 38 agents, 156 skills, 72 legacy command shims, production-ready hooks, and selective install workflows evolved through continuous real-world use",
|
||||
"author": {
|
||||
|
||||
@@ -43,10 +43,18 @@ jobs:
|
||||
|
||||
# Package manager setup
|
||||
- name: Setup pnpm
|
||||
if: matrix.pm == 'pnpm'
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
|
||||
if: matrix.pm == 'pnpm' && matrix.node != '18.x'
|
||||
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb # v6.0.0
|
||||
with:
|
||||
version: latest
|
||||
# Keep an explicit pnpm major because this repo's packageManager is Yarn.
|
||||
version: 10
|
||||
|
||||
- name: Setup pnpm (via Corepack)
|
||||
if: matrix.pm == 'pnpm' && matrix.node == '18.x'
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@9 --activate
|
||||
|
||||
- name: Setup Yarn (via Corepack)
|
||||
if: matrix.pm == 'yarn'
|
||||
@@ -79,6 +87,8 @@ jobs:
|
||||
if: matrix.pm == 'pnpm'
|
||||
id: pnpm-cache-dir
|
||||
shell: bash
|
||||
env:
|
||||
COREPACK_ENABLE_STRICT: '0'
|
||||
run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm
|
||||
@@ -130,7 +140,10 @@ jobs:
|
||||
run: |
|
||||
case "${{ matrix.pm }}" in
|
||||
npm) npm ci ;;
|
||||
pnpm) pnpm install --no-frozen-lockfile ;;
|
||||
# pnpm v10 can fail CI on ignored native build scripts
|
||||
# (for example msgpackr-extract) even though this repo is Yarn-native
|
||||
# and pnpm is only exercised here as a compatibility lane.
|
||||
pnpm) pnpm install --config.strict-dep-builds=false --no-frozen-lockfile ;;
|
||||
# Yarn Berry (v4+) removed --ignore-engines; engine checking is no longer a core feature
|
||||
yarn) yarn install ;;
|
||||
bun) bun install ;;
|
||||
@@ -146,7 +159,7 @@ jobs:
|
||||
# Upload test artifacts on failure
|
||||
- name: Upload test artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-${{ matrix.os }}-node${{ matrix.node }}-${{ matrix.pm }}
|
||||
path: |
|
||||
@@ -190,6 +203,10 @@ jobs:
|
||||
run: node scripts/ci/validate-install-manifests.js
|
||||
continue-on-error: false
|
||||
|
||||
- name: Validate workflow security
|
||||
run: node scripts/ci/validate-workflow-security.js
|
||||
continue-on-error: false
|
||||
|
||||
- name: Validate rules
|
||||
run: node scripts/ci/validate-rules.js
|
||||
continue-on-error: false
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update monthly metrics issue
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -22,6 +23,7 @@ jobs:
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -38,18 +40,38 @@ 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: Check npm publish state
|
||||
id: npm_publish_state
|
||||
run: |
|
||||
PACKAGE_NAME=$(node -p "require('./package.json').name")
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "already_published=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "already_published=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish npm package
|
||||
if: steps.npm_publish_state.outputs.already_published != 'true'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public --provenance
|
||||
|
||||
- name: Generate release highlights
|
||||
id: highlights
|
||||
env:
|
||||
@@ -70,11 +92,13 @@ jobs:
|
||||
- Improved release-note generation and changelog hygiene
|
||||
|
||||
### Notes
|
||||
- npm package: \`ecc-universal\`
|
||||
- Claude marketplace/plugin identifier: \`everything-claude-code@everything-claude-code\`
|
||||
- For migration tips and compatibility notes, see README and CHANGELOG.
|
||||
EOF
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
body_path: release_body.md
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -12,9 +12,24 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
required: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Version tag to release or republish (e.g., v1.10.0)'
|
||||
required: true
|
||||
type: string
|
||||
generate-notes:
|
||||
description: 'Auto-generate release notes'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -31,6 +46,7 @@ jobs:
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -47,6 +63,38 @@ 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: Check npm publish state
|
||||
id: npm_publish_state
|
||||
run: |
|
||||
PACKAGE_NAME=$(node -p "require('./package.json').name")
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "already_published=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "already_published=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish npm package
|
||||
if: steps.npm_publish_state.outputs.already_published != 'true'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public --provenance
|
||||
|
||||
- name: Generate release highlights
|
||||
env:
|
||||
TAG_NAME: ${{ inputs.tag }}
|
||||
@@ -59,10 +107,14 @@ jobs:
|
||||
- Harness reliability and cross-platform compatibility
|
||||
- Eval-driven quality improvements
|
||||
- Better workflow and operator ergonomics
|
||||
|
||||
### Package Notes
|
||||
- npm package: \`ecc-universal\`
|
||||
- Claude marketplace/plugin identifier: \`everything-claude-code@everything-claude-code\`
|
||||
EOF
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ inputs.tag }}
|
||||
body_path: release_body.md
|
||||
|
||||
@@ -35,10 +35,18 @@ jobs:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- name: Setup pnpm
|
||||
if: inputs.package-manager == 'pnpm'
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
|
||||
if: inputs.package-manager == 'pnpm' && inputs.node-version != '18.x'
|
||||
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb # v6.0.0
|
||||
with:
|
||||
version: latest
|
||||
# Keep an explicit pnpm major because this repo's packageManager is Yarn.
|
||||
version: 10
|
||||
|
||||
- name: Setup pnpm (via Corepack)
|
||||
if: inputs.package-manager == 'pnpm' && inputs.node-version == '18.x'
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@9 --activate
|
||||
|
||||
- name: Setup Yarn (via Corepack)
|
||||
if: inputs.package-manager == 'yarn'
|
||||
@@ -70,6 +78,8 @@ jobs:
|
||||
if: inputs.package-manager == 'pnpm'
|
||||
id: pnpm-cache-dir
|
||||
shell: bash
|
||||
env:
|
||||
COREPACK_ENABLE_STRICT: '0'
|
||||
run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm
|
||||
@@ -120,7 +130,10 @@ jobs:
|
||||
run: |
|
||||
case "${{ inputs.package-manager }}" in
|
||||
npm) npm ci ;;
|
||||
pnpm) pnpm install --no-frozen-lockfile ;;
|
||||
# pnpm v10 can fail CI on ignored native build scripts
|
||||
# (for example msgpackr-extract) even though this repo is Yarn-native
|
||||
# and pnpm is only exercised here as a compatibility lane.
|
||||
pnpm) pnpm install --config.strict-dep-builds=false --no-frozen-lockfile ;;
|
||||
# Yarn Berry (v4+) removed --ignore-engines; engine checking is no longer a core feature
|
||||
yarn) yarn install ;;
|
||||
bun) bun install ;;
|
||||
@@ -134,7 +147,7 @@ jobs:
|
||||
|
||||
- name: Upload test artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-${{ inputs.os }}-node${{ inputs.node-version }}-${{ inputs.package-manager }}
|
||||
path: |
|
||||
|
||||
@@ -42,6 +42,9 @@ jobs:
|
||||
- name: Validate install manifests
|
||||
run: node scripts/ci/validate-install-manifests.js
|
||||
|
||||
- name: Validate workflow security
|
||||
run: node scripts/ci/validate-workflow-security.js
|
||||
|
||||
- name: Validate rules
|
||||
run: node scripts/ci/validate-rules.js
|
||||
|
||||
|
||||
Generated
+95
-9
@@ -9,7 +9,7 @@
|
||||
"version": "1.10.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@opencode-ai/plugin": "^1.0.0",
|
||||
"@opencode-ai/plugin": "^1.4.3",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
@@ -21,22 +21,37 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.1.53",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.1.53.tgz",
|
||||
"integrity": "sha512-9ye7Wz2kESgt02AUDaMea4hXxj6XhWwKAG8NwFhrw09Ux54bGaMJFt1eIS8QQGIMaD+Lp11X4QdyEg96etEBJw==",
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.3.tgz",
|
||||
"integrity": "sha512-Ob/3tVSIeuMRJBr2O23RtrnC5djRe01Lglx+TwGEmjrH9yDBJ2tftegYLnNEjRoMuzITgq9LD8168p4pzv+U/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.1.53",
|
||||
"@opencode-ai/sdk": "1.4.3",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.97",
|
||||
"@opentui/solid": ">=0.1.97"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.1.53",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.53.tgz",
|
||||
"integrity": "sha512-RUIVnPOP1CyyU32FrOOYuE7Ge51lOBuhaFp2NSX98ncApT7ffoNetmwzqrhOiJQgZB1KrbCHLYOCK6AZfacxag==",
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.3.tgz",
|
||||
"integrity": "sha512-X0CAVbwoGAjTY2iecpWkx2B+GAa2jSaQKYpJ+xILopeF/OGKZUN15mjqci+L7cEuwLHV5wk3x2TStUOVCa5p0A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.33",
|
||||
@@ -48,6 +63,61 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -69,6 +139,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"@opencode-ai/plugin": ">=1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/plugin": "^1.0.0",
|
||||
"@opencode-ai/plugin": "^1.4.3",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
|
||||
@@ -456,7 +456,7 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
|
||||
const contextBlock = [
|
||||
"# ECC Context (preserve across compaction)",
|
||||
"",
|
||||
"## Active Plugin: Everything Claude Code v1.8.0",
|
||||
"## Active Plugin: Everything Claude Code v1.10.0",
|
||||
"- Hooks: file.edited, tool.execute.before/after, session.created/idle/deleted, shell.env, compacting, permission.ask",
|
||||
"- Tools: run-tests, check-coverage, security-audit, format-code, lint-check, git-summary, changed-files",
|
||||
"- Agents: 13 specialized (planner, architect, tdd-guide, code-reviewer, security-reviewer, build-error-resolver, e2e-runner, refactor-cleaner, doc-updater, go-reviewer, go-build-resolver, database-reviewer, python-reviewer)",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — Agent Instructions
|
||||
|
||||
This is a **production-ready AI coding plugin** providing 47 specialized agents, 181 skills, 79 commands, and automated hook workflows for software development.
|
||||
This is a **production-ready AI coding plugin** providing 48 specialized agents, 183 skills, 79 commands, and automated hook workflows for software development.
|
||||
|
||||
**Version:** 1.10.0
|
||||
|
||||
@@ -25,8 +25,8 @@ This is a **production-ready AI coding plugin** providing 47 specialized agents,
|
||||
| e2e-runner | End-to-end Playwright testing | Critical user flows |
|
||||
| refactor-cleaner | Dead code cleanup | Code maintenance |
|
||||
| doc-updater | Documentation and codemaps | Updating docs |
|
||||
| cpp-reviewer | C++ code review | C++ projects |
|
||||
| cpp-build-resolver | C++ build errors | C++ build failures |
|
||||
| cpp-reviewer | C/C++ code review | C and C++ projects |
|
||||
| cpp-build-resolver | C/C++ build errors | C and C++ build failures |
|
||||
| docs-lookup | Documentation lookup via Context7 | API/docs questions |
|
||||
| go-reviewer | Go code review | Go projects |
|
||||
| go-build-resolver | Go build errors | Go build failures |
|
||||
@@ -145,8 +145,8 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
agents/ — 47 specialized subagents
|
||||
skills/ — 181 workflow skills and domain knowledge
|
||||
agents/ — 48 specialized subagents
|
||||
skills/ — 183 workflow skills and domain knowledge
|
||||
commands/ — 79 slash commands
|
||||
hooks/ — Trigger-based automations
|
||||
rules/ — Always-follow guidelines (common + per-language)
|
||||
|
||||
@@ -84,6 +84,7 @@ This repo is the raw code only. The guides explain everything.
|
||||
|
||||
### v1.10.0 — Surface Refresh, Operator Workflows, and ECC 2.0 Alpha (Apr 2026)
|
||||
|
||||
- **Dashboard GUI** — New Tkinter-based desktop application (`ecc_dashboard.py` or `npm run dashboard`) with dark/light theme toggle, font customization, and project logo in header and taskbar.
|
||||
- **Public surface synced to the live repo** — metadata, catalog counts, plugin manifests, and install-facing docs now match the actual OSS surface: 38 agents, 156 skills, and 72 legacy command shims.
|
||||
- **Operator and outbound workflow expansion** — `brand-voice`, `social-graph-ranker`, `connections-optimizer`, `customer-billing-ops`, `ecc-tools-cost-audit`, `google-workspace-ops`, `project-flow-ops`, and `workspace-surface-audit` round out the operator lane.
|
||||
- **Media and launch tooling** — `manim-video`, `remotion-video-creation`, and upgraded social publishing surfaces make technical explainers and launch content part of the same system.
|
||||
@@ -173,12 +174,26 @@ Get up and running in under 2 minutes:
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Install plugin
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
### Naming + Migration Note
|
||||
|
||||
ECC now has three public identifiers, and they are not interchangeable:
|
||||
|
||||
- GitHub source repo: `affaan-m/everything-claude-code`
|
||||
- Claude marketplace/plugin identifier: `everything-claude-code@everything-claude-code`
|
||||
- npm package: `ecc-universal`
|
||||
|
||||
This is intentional. Anthropic marketplace/plugin installs are keyed by a canonical plugin identifier, so ECC standardized on `everything-claude-code@everything-claude-code` to keep the listing name, `/plugin install`, `/plugin list`, and repo docs aligned to one public install surface. Older posts may still show the old short-form nickname; that shorthand is deprecated. Separately, the npm package stayed on `ecc-universal`, so npm installs and marketplace installs intentionally use different names.
|
||||
|
||||
### Step 2: Install Rules (Required)
|
||||
|
||||
> WARNING: **Important:** Claude Code plugins cannot distribute `rules` automatically. Install them manually:
|
||||
>
|
||||
> If your local Claude setup was wiped or reset, that does not mean you need to repurchase ECC. Start with `ecc list-installed`, then run `ecc doctor` and `ecc repair` before reinstalling anything. That usually restores ECC-managed files without rebuilding your setup. If the problem is account or marketplace access for ECC Tools, handle billing/account recovery separately.
|
||||
|
||||
> If your local Claude setup was wiped or reset, that does not mean you need to repurchase ECC. Start with `ecc list-installed`, then run `ecc doctor` and `ecc repair` before reinstalling anything. That usually restores ECC-managed files without rebuilding your setup. If the problem is account or marketplace access for ECC Tools, handle billing/account recovery separately.
|
||||
|
||||
```bash
|
||||
# Clone the repo first
|
||||
@@ -233,10 +248,27 @@ For manual install instructions see the README in the `rules/` folder. When copy
|
||||
# /plan "Add user authentication"
|
||||
|
||||
# Check available commands
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
**That's it!** You now have access to 47 agents, 181 skills, and 79 legacy command shims.
|
||||
**That's it!** You now have access to 48 agents, 183 skills, and 79 legacy command shims.
|
||||
|
||||
### Dashboard GUI
|
||||
|
||||
Launch the desktop dashboard to visually explore ECC components:
|
||||
|
||||
```bash
|
||||
npm run dashboard
|
||||
# or
|
||||
python3 ./ecc_dashboard.py
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Tabbed interface: Agents, Skills, Commands, Rules, Settings
|
||||
- Dark/Light theme toggle
|
||||
- Font customization (family & size)
|
||||
- Project logo in header and taskbar
|
||||
- Search and filter across all components
|
||||
|
||||
### Multi-model commands require additional setup
|
||||
|
||||
@@ -351,7 +383,7 @@ everything-claude-code/
|
||||
| |-- market-research/ # Source-attributed market, competitor, and investor research (NEW)
|
||||
| |-- investor-materials/ # Pitch decks, one-pagers, memos, and financial models (NEW)
|
||||
| |-- investor-outreach/ # Personalized fundraising outreach and follow-up (NEW)
|
||||
| |-- continuous-learning/ # Auto-extract patterns from sessions (Longform Guide)
|
||||
| |-- continuous-learning/ # Legacy v1 Stop-hook pattern extraction
|
||||
| |-- continuous-learning-v2/ # Instinct-based learning with confidence scoring
|
||||
| |-- iterative-retrieval/ # Progressive context refinement for subagents
|
||||
| |-- strategic-compact/ # Manual compaction suggestions (Longform Guide)
|
||||
@@ -498,6 +530,12 @@ everything-claude-code/
|
||||
|-- mcp-configs/ # MCP server configurations
|
||||
| |-- mcp-servers.json # GitHub, Supabase, Vercel, Railway, etc.
|
||||
|
|
||||
|-- ecc_dashboard.py # Desktop GUI dashboard (Tkinter)
|
||||
|
|
||||
|-- assets/ # Assets for dashboard
|
||||
| |-- images/
|
||||
| |-- ecc-logo.png
|
||||
|
|
||||
|-- marketplace.json # Self-hosted marketplace config (for /plugin marketplace add)
|
||||
```
|
||||
|
||||
@@ -515,7 +553,7 @@ Use the `/skill-create` command for local analysis without external services:
|
||||
|
||||
```bash
|
||||
/skill-create # Analyze current repo
|
||||
/skill-create --instincts # Also generate instincts for continuous-learning
|
||||
/skill-create --instincts # Also generate instincts for continuous-learning-v2
|
||||
```
|
||||
|
||||
This analyzes your git history locally and generates SKILL.md files.
|
||||
@@ -580,6 +618,7 @@ The instinct-based learning system automatically learns your patterns:
|
||||
```
|
||||
|
||||
See `skills/continuous-learning-v2/` for full documentation.
|
||||
Keep `continuous-learning/` only when you explicitly want the legacy v1 Stop-hook learned-skill flow.
|
||||
|
||||
---
|
||||
|
||||
@@ -621,7 +660,7 @@ The easiest way to use this repo - install as a Claude Code plugin:
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Install the plugin
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
Or add directly to your `~/.claude/settings.json`:
|
||||
@@ -637,7 +676,7 @@ Or add directly to your `~/.claude/settings.json`:
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"ecc@ecc": true
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -700,9 +739,27 @@ mkdir -p ~/.claude/commands
|
||||
cp everything-claude-code/commands/*.md ~/.claude/commands/
|
||||
```
|
||||
|
||||
#### Add hooks to settings.json
|
||||
#### Install hooks
|
||||
|
||||
Copy the hooks from `hooks/hooks.json` to your `~/.claude/settings.json`.
|
||||
Do not copy the raw repo `hooks/hooks.json` into `~/.claude/settings.json` or `~/.claude/hooks/hooks.json`. That file is plugin/repo-oriented and is meant to be installed through the ECC installer or loaded as a plugin, so raw copying is not a supported manual install path.
|
||||
|
||||
Use the installer to install only the Claude hook runtime so command paths are rewritten correctly:
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
bash ./install.sh --target claude --modules hooks-runtime
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
pwsh -File .\install.ps1 --target claude --modules hooks-runtime
|
||||
```
|
||||
|
||||
That writes resolved hooks to `~/.claude/hooks/hooks.json` and leaves any existing `~/.claude/settings.json` untouched.
|
||||
|
||||
If you installed ECC via `/plugin install`, do not copy those hooks into `settings.json`. Claude Code v2.1+ already auto-loads plugin `hooks/hooks.json`, and duplicating them in `settings.json` causes duplicate execution and cross-platform hook conflicts.
|
||||
|
||||
Windows note: the Claude config directory is `%USERPROFILE%\\.claude`, not `~/claude`.
|
||||
|
||||
#### Configure MCPs
|
||||
|
||||
@@ -837,7 +894,7 @@ Slash forms below are shown because they are still the fastest familiar entrypoi
|
||||
<summary><b>How do I check which agents/commands are installed?</b></summary>
|
||||
|
||||
```bash
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
This shows all available agents, commands, and skills from the plugin.
|
||||
@@ -968,6 +1025,14 @@ Please contribute! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
- Testing strategies (different frameworks, visual regression)
|
||||
- Domain-specific knowledge (ML, data engineering, mobile)
|
||||
|
||||
### Community Ecosystem Notes
|
||||
|
||||
These are not bundled with ECC and are not audited by this repo, but they are worth knowing about if you are exploring the broader Claude Code skills ecosystem:
|
||||
|
||||
- [claude-seo](https://github.com/AgriciDaniel/claude-seo) — SEO-focused skill and agent collection
|
||||
- [claude-ads](https://github.com/AgriciDaniel/claude-ads) — Ad-audit and paid-growth workflow collection
|
||||
- [claude-cybersecurity](https://github.com/AgriciDaniel/claude-cybersecurity) — Security-oriented skill and agent collection
|
||||
|
||||
---
|
||||
|
||||
## Cursor IDE Support
|
||||
@@ -1152,9 +1217,9 @@ The configuration is automatically detected from `.opencode/opencode.json`.
|
||||
|
||||
| Feature | Claude Code | OpenCode | Status |
|
||||
|---------|-------------|----------|--------|
|
||||
| Agents | PASS: 47 agents | PASS: 12 agents | **Claude Code leads** |
|
||||
| Agents | PASS: 48 agents | PASS: 12 agents | **Claude Code leads** |
|
||||
| Commands | PASS: 79 commands | PASS: 31 commands | **Claude Code leads** |
|
||||
| Skills | PASS: 181 skills | PASS: 37 skills | **Claude Code leads** |
|
||||
| Skills | PASS: 183 skills | PASS: 37 skills | **Claude Code leads** |
|
||||
| Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** |
|
||||
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
|
||||
| MCP Servers | PASS: 14 servers | PASS: Full | **Full parity** |
|
||||
@@ -1261,9 +1326,9 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e
|
||||
|
||||
| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|
||||
|---------|------------|------------|-----------|----------|
|
||||
| **Agents** | 47 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |
|
||||
| **Agents** | 48 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |
|
||||
| **Commands** | 79 | Shared | Instruction-based | 31 |
|
||||
| **Skills** | 181 | Shared | 10 (native format) | 37 |
|
||||
| **Skills** | 183 | Shared | 10 (native format) | 37 |
|
||||
| **Hook Events** | 8 types | 15 types | None yet | 11 types |
|
||||
| **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks |
|
||||
| **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions |
|
||||
|
||||
+23
-8
@@ -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 分钟内快速上手:
|
||||
@@ -88,12 +99,14 @@
|
||||
|
||||
```bash
|
||||
# 添加市场
|
||||
/plugin marketplace add affaan-m/everything-claude-code
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# 安装插件
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
> 安装名称说明:较早的帖子里可能还会出现旧的短别名。那个旧缩写现在已经废弃。Anthropic 的 marketplace/plugin 安装是按规范化插件标识符寻址的,因此 ECC 统一为 `everything-claude-code@everything-claude-code`,这样市场条目、安装命令、`/plugin list` 输出和仓库文档都使用同一个公开名称,不再出现两个名字指向同一插件的混乱。
|
||||
|
||||
### 第二步:安装规则(必需)
|
||||
|
||||
> WARNING: **重要提示:** Claude Code 插件无法自动分发 `rules`,需要手动安装:
|
||||
@@ -148,10 +161,10 @@ npx ecc-install typescript
|
||||
# /plan "添加用户认证"
|
||||
|
||||
# 查看可用命令
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
**完成!** 你现在可以使用 47 个代理、181 个技能和 79 个命令。
|
||||
**完成!** 你现在可以使用 48 个代理、183 个技能和 79 个命令。
|
||||
|
||||
### multi-* 命令需要额外配置
|
||||
|
||||
@@ -532,10 +545,10 @@ Claude Code v2.1+ 会**按照约定自动加载**已安装插件中的 `hooks/ho
|
||||
|
||||
```bash
|
||||
# 将此仓库添加为市场
|
||||
/plugin marketplace add affaan-m/everything-claude-code
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# 安装插件
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
或直接添加到你的 `~/.claude/settings.json`:
|
||||
@@ -551,7 +564,7 @@ Claude Code v2.1+ 会**按照约定自动加载**已安装插件中的 `hooks/ho
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"ecc@ecc": true
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -615,7 +628,9 @@ cp everything-claude-code/commands/*.md ~/.claude/commands/
|
||||
```
|
||||
|
||||
#### 将钩子配置添加到 settings.json
|
||||
将 `hooks/hooks.json` 中的钩子配置复制到你的 `~/.claude/settings.json` 文件中。
|
||||
仅适用于手动安装:如果你没有通过 Claude 插件方式安装 ECC,可以将 `hooks/hooks.json` 中的钩子配置复制到你的 `~/.claude/settings.json` 文件中。
|
||||
|
||||
如果你是通过 `/plugin install` 安装 ECC,请不要再把这些钩子复制到 `settings.json`。Claude Code v2.1+ 会自动加载插件中的 `hooks/hooks.json`,重复注册会导致重复执行以及 `${CLAUDE_PLUGIN_ROOT}` 无法解析。
|
||||
|
||||
#### 配置 MCP 服务
|
||||
从 `mcp-configs/mcp-servers.json` 中复制需要的 MCP 服务定义,粘贴到官方 Claude Code 配置文件 `~/.claude/settings.json` 中;
|
||||
|
||||
@@ -245,9 +245,17 @@ tmux attach -t dev
|
||||
- Marketplace cache not updated
|
||||
- Claude Code version incompatibility
|
||||
- Corrupted plugin files
|
||||
- Local Claude setup was wiped or reset
|
||||
|
||||
**Solutions:**
|
||||
```bash
|
||||
# First inspect what ECC still knows about this machine
|
||||
ecc list-installed
|
||||
ecc doctor
|
||||
ecc repair
|
||||
|
||||
# Only reinstall if doctor/repair cannot restore the missing files
|
||||
|
||||
# Inspect the plugin cache before changing it
|
||||
ls -la ~/.claude/plugins/cache/
|
||||
|
||||
@@ -259,6 +267,8 @@ mkdir -p ~/.claude/plugins/cache
|
||||
# Claude Code → Extensions → Everything Claude Code → Uninstall
|
||||
# Then reinstall from marketplace
|
||||
|
||||
# If the issue is marketplace/account access, use ECC Tools billing/account recovery separately; do not use reinstall as a proxy for account recovery
|
||||
|
||||
# Check Claude Code version
|
||||
claude --version
|
||||
# Requires Claude Code 2.0+
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# Working Context
|
||||
|
||||
Last updated: 2026-04-05
|
||||
Last updated: 2026-04-08
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -10,7 +10,7 @@ Public ECC plugin repo for agents, skills, commands, hooks, rules, install surfa
|
||||
|
||||
- Default branch: `main`
|
||||
- Public release surface is aligned at `v1.10.0`
|
||||
- Public catalog truth is `39` agents, `73` commands, and `179` skills
|
||||
- Public catalog truth is `47` agents, `79` commands, and `181` skills
|
||||
- Public plugin slug is now `ecc`; legacy `everything-claude-code` install paths remain supported for compatibility
|
||||
- Release discussion: `#1272`
|
||||
- ECC 2.0 exists in-tree and builds, but it is still alpha rather than GA
|
||||
@@ -36,6 +36,7 @@ Public ECC plugin repo for agents, skills, commands, hooks, rules, install surfa
|
||||
- control plane primitives
|
||||
- operator surface
|
||||
- self-improving skills
|
||||
- keep `agent.yaml` export parity with the shipped `commands/` and `skills/` directories so modern install surfaces do not silently lose command registration
|
||||
- Skill quality:
|
||||
- rewrite content-facing skills to use source-backed voice modeling
|
||||
- remove generic LLM rhetoric, canned CTA patterns, and forced platform stereotypes
|
||||
@@ -175,3 +176,4 @@ Keep this file detailed for only the current sprint, blockers, and next actions.
|
||||
- `skills/oura-health` and `skills/pmx-guidelines` are user- or project-specific, not canonical ECC surfaces
|
||||
- `docs/releases/2.0.0-preview/*` is premature collateral and should be rebuilt from current product truth later
|
||||
- nested `skills/hermes-generated/*` is superseded by the top-level ECC-native operator skills already ported to `main`
|
||||
- 2026-04-08: Fixed the command-export regression reported in `#1327` by restoring a canonical `commands:` section in `agent.yaml` and adding `tests/ci/agent-yaml-surface.test.js` to enforce exact parity between the YAML export surface and the real `commands/` directory. Verified with the full repo test sweep: `1764/1764` passing.
|
||||
|
||||
+80
@@ -143,6 +143,86 @@ skills:
|
||||
- videodb
|
||||
- visa-doc-translate
|
||||
- x-api
|
||||
commands:
|
||||
- agent-sort
|
||||
- aside
|
||||
- build-fix
|
||||
- checkpoint
|
||||
- claw
|
||||
- code-review
|
||||
- context-budget
|
||||
- cpp-build
|
||||
- cpp-review
|
||||
- cpp-test
|
||||
- devfleet
|
||||
- docs
|
||||
- e2e
|
||||
- eval
|
||||
- evolve
|
||||
- feature-dev
|
||||
- flutter-build
|
||||
- flutter-review
|
||||
- flutter-test
|
||||
- gan-build
|
||||
- gan-design
|
||||
- go-build
|
||||
- go-review
|
||||
- go-test
|
||||
- gradle-build
|
||||
- harness-audit
|
||||
- hookify
|
||||
- hookify-configure
|
||||
- hookify-help
|
||||
- hookify-list
|
||||
- instinct-export
|
||||
- instinct-import
|
||||
- instinct-status
|
||||
- jira
|
||||
- kotlin-build
|
||||
- kotlin-review
|
||||
- kotlin-test
|
||||
- learn
|
||||
- learn-eval
|
||||
- loop-start
|
||||
- loop-status
|
||||
- model-route
|
||||
- multi-backend
|
||||
- multi-execute
|
||||
- multi-frontend
|
||||
- multi-plan
|
||||
- multi-workflow
|
||||
- orchestrate
|
||||
- plan
|
||||
- pm2
|
||||
- projects
|
||||
- promote
|
||||
- prompt-optimize
|
||||
- prp-commit
|
||||
- prp-implement
|
||||
- prp-plan
|
||||
- prp-pr
|
||||
- prp-prd
|
||||
- prune
|
||||
- python-review
|
||||
- quality-gate
|
||||
- refactor-clean
|
||||
- resume-session
|
||||
- review-pr
|
||||
- rules-distill
|
||||
- rust-build
|
||||
- rust-review
|
||||
- rust-test
|
||||
- santa-loop
|
||||
- save-session
|
||||
- sessions
|
||||
- setup-pm
|
||||
- skill-create
|
||||
- skill-health
|
||||
- tdd
|
||||
- test-coverage
|
||||
- update-codemaps
|
||||
- update-docs
|
||||
- verify
|
||||
tags:
|
||||
- agent-harness
|
||||
- developer-tools
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: a11y-architect
|
||||
description: Accessibility Architect specializing in WCAG 2.2 compliance for Web and Native platforms. Use PROACTIVELY when designing UI components, establishing design systems, or auditing code for inclusive user experiences.
|
||||
model: sonnet
|
||||
tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"]
|
||||
model: opus
|
||||
---
|
||||
|
||||
You are a Senior Accessibility Architect. Your goal is to ensure that every digital product is Perceivable, Operable, Understandable, and Robust (POUR) for all users, including those with visual, auditory, motor, or cognitive disabilities.
|
||||
|
||||
## Your Role
|
||||
|
||||
- **Architecting Inclusivity**: Design UI systems that natively support assistive technologies (Screen Readers, Voice Control, Switch Access).
|
||||
- **WCAG 2.2 Enforcement**: Apply the latest success criteria, focusing on new standards like Focus Appearance, Target Size, and Redundant Entry.
|
||||
- **Platform Strategy**: Bridge the gap between Web standards (WAI-ARIA) and Native frameworks (SwiftUI/Jetpack Compose).
|
||||
- **Technical Specifications**: Provide developers with precise attributes (roles, labels, hints, and traits) required for compliance.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Contextual Discovery
|
||||
|
||||
- Determine if the target is **Web**, **iOS**, or **Android**.
|
||||
- Analyze the user interaction (e.g., Is this a simple button or a complex data grid?).
|
||||
- Identify potential accessibility "blockers" (e.g., color-only indicators, missing focus containment in modals).
|
||||
|
||||
### Step 2: Strategic Implementation
|
||||
|
||||
- **Apply the Accessibility Skill**: Invoke specific logic to generate semantic code.
|
||||
- **Define Focus Flow**: Map out how a keyboard or screen reader user will move through the interface.
|
||||
- **Optimize Touch/Pointer**: Ensure all interactive elements meet the minimum **24x24 pixel** spacing or **44x44 pixel** target size requirements.
|
||||
|
||||
### Step 3: Validation & Documentation
|
||||
|
||||
- Review the output against the WCAG 2.2 Level AA checklist.
|
||||
- Provide a brief "Implementation Note" explaining _why_ certain attributes (like `aria-live` or `accessibilityHint`) were used.
|
||||
|
||||
## Output Format
|
||||
|
||||
For every component or page request, provide:
|
||||
|
||||
1. **The Code**: Semantic HTML/ARIA or Native code.
|
||||
2. **The Accessibility Tree**: A description of what a screen reader will announce.
|
||||
3. **Compliance Mapping**: A list of specific WCAG 2.2 criteria addressed.
|
||||
|
||||
## Examples
|
||||
|
||||
### Example: Accessible Search Component
|
||||
|
||||
**Input**: "Create a search bar with a submit icon."
|
||||
**Action**: Ensuring the icon-only button has a visible label and the input is correctly labeled.
|
||||
**Output**:
|
||||
|
||||
```html
|
||||
<form role="search">
|
||||
<label for="site-search" class="sr-only">Search the site</label>
|
||||
<input type="search" id="site-search" name="q" />
|
||||
<button type="submit" aria-label="Search">
|
||||
<svg aria-hidden="true">...</svg>
|
||||
</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
## WCAG 2.2 Core Compliance Checklist
|
||||
|
||||
### 1. Perceivable (Information must be presentable)
|
||||
|
||||
- [ ] **Text Alternatives**: All non-text content has a text alternative (Alt text or labels).
|
||||
- [ ] **Contrast**: Text meets 4.5:1; UI components/graphics meet 3:1 contrast ratios.
|
||||
- [ ] **Adaptable**: Content reflows and remains functional when resized up to 400%.
|
||||
|
||||
### 2. Operable (Interface components must be usable)
|
||||
|
||||
- [ ] **Keyboard Accessible**: Every interactive element is reachable via keyboard/switch control.
|
||||
- [ ] **Navigable**: Focus order is logical, and focus indicators are high-contrast (SC 2.4.11).
|
||||
- [ ] **Pointer Gestures**: Single-pointer alternatives exist for all dragging or multipoint gestures.
|
||||
- [ ] **Target Size**: Interactive elements are at least 24x24 CSS pixels (SC 2.5.8).
|
||||
|
||||
### 3. Understandable (Information must be clear)
|
||||
|
||||
- [ ] **Predictable**: Navigation and identification of elements are consistent across the app.
|
||||
- [ ] **Input Assistance**: Forms provide clear error identification and suggestions for fix.
|
||||
- [ ] **Redundant Entry**: Avoid asking for the same info twice in a single process (SC 3.3.7).
|
||||
|
||||
### 4. Robust (Content must be compatible)
|
||||
|
||||
- [ ] **Compatibility**: Maximize compatibility with assistive tech using valid Name, Role, and Value.
|
||||
- [ ] **Status Messages**: Screen readers are notified of dynamic changes via ARIA live regions.
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Issue | Why it fails |
|
||||
| :------------------------- | :------------------------------------------------------------------------------------------------- |
|
||||
| **"Click Here" Links** | Non-descriptive; screen reader users navigating by links won't know the destination. |
|
||||
| **Fixed-Sized Containers** | Prevents content reflow and breaks the layout at higher zoom levels. |
|
||||
| **Keyboard Traps** | Prevents users from navigating the rest of the page once they enter a component. |
|
||||
| **Auto-Playing Media** | Distracting for users with cognitive disabilities; interferes with screen reader audio. |
|
||||
| **Empty Buttons** | Icon-only buttons without an `aria-label` or `accessibilityLabel` are invisible to screen readers. |
|
||||
|
||||
## Accessibility Decision Record Template
|
||||
|
||||
For major UI decisions, use this format:
|
||||
|
||||
````markdown
|
||||
# ADR-ACC-[000]: [Title of the Accessibility Decision]
|
||||
|
||||
## Status
|
||||
|
||||
Proposed | **Accepted** | Deprecated | Superseded by [ADR-XXX]
|
||||
|
||||
## Context
|
||||
|
||||
_Describe the UI component or workflow being addressed._
|
||||
|
||||
- **Platform**: [Web | iOS | Android | Cross-platform]
|
||||
- **WCAG 2.2 Success Criterion**: [e.g., 2.5.8 Target Size (Minimum)]
|
||||
- **Problem**: What is the current accessibility barrier? (e.g., "The 'Close' button in the modal is too small for users with motor impairments.")
|
||||
|
||||
## Decision
|
||||
|
||||
_Detail the specific implementation choice._
|
||||
"We will implement a touch target of at least 44x44 points for all mobile navigation elements and 24x24 CSS pixels for web, ensuring a minimum 4px spacing between adjacent targets."
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Code/Spec
|
||||
|
||||
```[language]
|
||||
// Example: SwiftUI
|
||||
Button(action: close) {
|
||||
Image(systemName: "xmark")
|
||||
.frame(width: 44, height: 44) // Standardizing hit area
|
||||
}
|
||||
.accessibilityLabel("Close modal")
|
||||
```
|
||||
````
|
||||
|
||||
## Reference
|
||||
|
||||
- See skill `accessibility` to transform raw UI requirements into platform-specific accessible code (WAI-ARIA, SwiftUI, or Jetpack Compose) based on WCAG 2.2 criteria.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Quick commit with natural language file targeting — describe what to commit in plain English
|
||||
argument-hint: [target description] (blank = all changes)
|
||||
description: "Quick commit with natural language file targeting — describe what to commit in plain English"
|
||||
argument-hint: "[target description] (blank = all changes)"
|
||||
---
|
||||
|
||||
# Smart Commit
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Create a GitHub PR from current branch with unpushed commits — discovers templates, analyzes changes, pushes
|
||||
argument-hint: [base-branch] (default: main)
|
||||
description: "Create a GitHub PR from current branch with unpushed commits — discovers templates, analyzes changes, pushes"
|
||||
argument-hint: "[base-branch] (default: main)"
|
||||
---
|
||||
|
||||
# Create Pull Request
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Interactive PRD generator - problem-first, hypothesis-driven product spec with back-and-forth questioning
|
||||
argument-hint: [feature/product idea] (blank = start with questions)
|
||||
description: "Interactive PRD generator - problem-first, hypothesis-driven product spec with back-and-forth questioning"
|
||||
argument-hint: "[feature/product idea] (blank = start with questions)"
|
||||
---
|
||||
|
||||
# Product Requirements Document Generator
|
||||
|
||||
+16
-10
@@ -29,8 +29,9 @@ Use `/sessions info` when you need operator-surface context for a swarm: branch,
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const sm = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-manager');
|
||||
const aa = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
|
||||
const _r = (()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();
|
||||
const sm = require(_r + '/scripts/lib/session-manager');
|
||||
const aa = require(_r + '/scripts/lib/session-aliases');
|
||||
const path = require('path');
|
||||
|
||||
const result = sm.getAllSessions({ limit: 20 });
|
||||
@@ -70,8 +71,9 @@ Load and display a session's content (by ID or alias).
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const sm = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-manager');
|
||||
const aa = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
|
||||
const _r = (()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();
|
||||
const sm = require(_r + '/scripts/lib/session-manager');
|
||||
const aa = require(_r + '/scripts/lib/session-aliases');
|
||||
const id = process.argv[1];
|
||||
|
||||
// First try to resolve as alias
|
||||
@@ -143,8 +145,9 @@ Create a memorable alias for a session.
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const sm = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-manager');
|
||||
const aa = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
|
||||
const _r = (()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();
|
||||
const sm = require(_r + '/scripts/lib/session-manager');
|
||||
const aa = require(_r + '/scripts/lib/session-aliases');
|
||||
|
||||
const sessionId = process.argv[1];
|
||||
const aliasName = process.argv[2];
|
||||
@@ -183,7 +186,8 @@ Delete an existing alias.
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const aa = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
|
||||
const _r = (()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();
|
||||
const aa = require(_r + '/scripts/lib/session-aliases');
|
||||
|
||||
const aliasName = process.argv[1];
|
||||
if (!aliasName) {
|
||||
@@ -212,8 +216,9 @@ Show detailed information about a session.
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const sm = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-manager');
|
||||
const aa = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
|
||||
const _r = (()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();
|
||||
const sm = require(_r + '/scripts/lib/session-manager');
|
||||
const aa = require(_r + '/scripts/lib/session-aliases');
|
||||
|
||||
const id = process.argv[1];
|
||||
const resolved = aa.resolveAlias(id);
|
||||
@@ -262,7 +267,8 @@ Show all session aliases.
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const aa = require((()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()+'/scripts/lib/session-aliases');
|
||||
const _r = (()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();
|
||||
const aa = require(_r + '/scripts/lib/session-aliases');
|
||||
|
||||
const aliases = aa.listAliases();
|
||||
console.log('Session Aliases (' + aliases.length + '):');
|
||||
|
||||
@@ -13,21 +13,21 @@ Shows a comprehensive health dashboard for all skills in the portfolio with succ
|
||||
Run the skill health CLI in dashboard mode:
|
||||
|
||||
```bash
|
||||
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(!f.existsSync(p.join(d,q))){try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q))){d=c;break}}}catch(x){}}console.log(d)")}"
|
||||
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();console.log(r)")}"
|
||||
node "$ECC_ROOT/scripts/skills-health.js" --dashboard
|
||||
```
|
||||
|
||||
For a specific panel only:
|
||||
|
||||
```bash
|
||||
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(!f.existsSync(p.join(d,q))){try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q))){d=c;break}}}catch(x){}}console.log(d)")}"
|
||||
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();console.log(r)")}"
|
||||
node "$ECC_ROOT/scripts/skills-health.js" --dashboard --panel failures
|
||||
```
|
||||
|
||||
For machine-readable output:
|
||||
|
||||
```bash
|
||||
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(!f.existsSync(p.join(d,q))){try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q))){d=c;break}}}catch(x){}}console.log(d)")}"
|
||||
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();console.log(r)")}"
|
||||
node "$ECC_ROOT/scripts/skills-health.js" --dashboard --json
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# プラグインをインストール
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
### ステップ2:ルールをインストール(必須)
|
||||
@@ -140,7 +140,7 @@ cp -r everything-claude-code/rules/golang/* ~/.claude/rules/
|
||||
# /plan "ユーザー認証を追加"
|
||||
|
||||
# 利用可能なコマンドを確認
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
**完了です!** これで13のエージェント、43のスキル、31のコマンドにアクセスできます。
|
||||
@@ -427,7 +427,7 @@ Duplicate hook file detected: ./hooks/hooks.json is already resolved to a loaded
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# プラグインをインストール
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
または、`~/.claude/settings.json` に直接追加:
|
||||
@@ -443,7 +443,7 @@ Duplicate hook file detected: ./hooks/hooks.json is already resolved to a loaded
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"ecc@ecc": true
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -497,7 +497,9 @@ cp -r everything-claude-code/skills/* ~/.claude/skills/
|
||||
|
||||
#### settings.json にフックを追加
|
||||
|
||||
`hooks/hooks.json` のフックを `~/.claude/settings.json` にコピーします。
|
||||
手動インストール時のみ、`hooks/hooks.json` のフックを `~/.claude/settings.json` にコピーします。
|
||||
|
||||
`/plugin install` で ECC を導入した場合は、これらのフックを `settings.json` にコピーしないでください。Claude Code v2.1+ はプラグインの `hooks/hooks.json` を自動読み込みするため、二重登録すると重複実行や `${CLAUDE_PLUGIN_ROOT}` の解決失敗が発生します。
|
||||
|
||||
#### MCP を設定
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Everything Claude Code プロジェクトのインタラクティブなステッ
|
||||
## 前提条件
|
||||
|
||||
このスキルは起動前に Claude Code からアクセス可能である必要があります。ブートストラップには2つの方法があります:
|
||||
1. **プラグイン経由**: `/plugin install ecc@ecc` — プラグインがこのスキルを自動的にロードします
|
||||
1. **プラグイン経由**: `/plugin install everything-claude-code@everything-claude-code` — プラグインがこのスキルを自動的にロードします
|
||||
2. **手動**: このスキルのみを `~/.claude/skills/configure-ecc/SKILL.md` にコピーし、"configure ecc" と言って起動します
|
||||
|
||||
---
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# 플러그인 설치
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
### 2단계: 룰 설치 (필수)
|
||||
@@ -147,7 +147,7 @@ cd everything-claude-code
|
||||
# /plan "사용자 인증 추가"
|
||||
|
||||
# 사용 가능한 커맨드 확인
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
**끝!** 이제 16개 에이전트, 65개 스킬, 40개 커맨드를 사용할 수 있습니다.
|
||||
@@ -359,7 +359,7 @@ Claude Code v2.1+는 설치된 플러그인의 `hooks/hooks.json`을 **자동으
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# 플러그인 설치
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
또는 `~/.claude/settings.json`에 직접 추가:
|
||||
@@ -375,7 +375,7 @@ Claude Code v2.1+는 설치된 플러그인의 `hooks/hooks.json`을 **자동으
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"ecc@ecc": true
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -535,7 +535,7 @@ rules/
|
||||
<summary><b>설치된 에이전트/커맨드 확인은 어떻게 하나요?</b></summary>
|
||||
|
||||
```bash
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
플러그인에서 사용할 수 있는 모든 에이전트, 커맨드, 스킬을 보여줍니다.
|
||||
|
||||
+14
-5
@@ -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.
|
||||
@@ -115,7 +124,7 @@ Comece em menos de 2 minutos:
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Instalar plugin
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
### Passo 2: Instalar as Regras (Obrigatório)
|
||||
@@ -158,7 +167,7 @@ npx ecc-install typescript
|
||||
# /plan "Adicionar autenticação de usuário"
|
||||
|
||||
# Verificar comandos disponíveis
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
**Pronto!** Você agora tem acesso a 28 agentes, 116 skills e 59 comandos.
|
||||
@@ -304,7 +313,7 @@ claude --version
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Instalar o plugin
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
Ou adicione diretamente ao seu `~/.claude/settings.json`:
|
||||
@@ -320,7 +329,7 @@ Ou adicione diretamente ao seu `~/.claude/settings.json`:
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"ecc@ecc": true
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -443,7 +452,7 @@ Regras são diretrizes sempre seguidas, organizadas em `common/` (agnóstico à
|
||||
<summary><b>Como verificar quais agentes/comandos estão instalados?</b></summary>
|
||||
|
||||
```bash
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
+12
-3
@@ -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.
|
||||
@@ -116,7 +125,7 @@ Bu repository yalnızca ham kodu içerir. Rehberler her şeyi açıklıyor.
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Plugin'i kur
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
### Adım 2: Rule'ları Kurun (Gerekli)
|
||||
@@ -161,7 +170,7 @@ Manuel kurulum talimatları için `rules/` klasöründeki README'ye bakın.
|
||||
# /plan "Kullanıcı kimlik doğrulaması ekle"
|
||||
|
||||
# Mevcut command'ları kontrol edin
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
**Bu kadar!** Artık 28 agent, 116 skill ve 59 command'a erişiminiz var.
|
||||
@@ -343,7 +352,7 @@ Nereden başlayacağınızdan emin değil misiniz? Bu hızlı referansı kullan
|
||||
<summary><b>Hangi agent/command'ların kurulu olduğunu nasıl kontrol ederim?</b></summary>
|
||||
|
||||
```bash
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
Bu, plugin'den mevcut tüm agent'ları, command'ları ve skill'leri gösterir.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — 智能体指令
|
||||
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 47 个专业代理、181 项技能、79 条命令以及自动化钩子工作流,用于软件开发。
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 48 个专业代理、183 项技能、79 条命令以及自动化钩子工作流,用于软件开发。
|
||||
|
||||
**版本:** 1.10.0
|
||||
|
||||
@@ -146,8 +146,8 @@
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
agents/ — 47 个专业子代理
|
||||
skills/ — 181 个工作流技能和领域知识
|
||||
agents/ — 48 个专业子代理
|
||||
skills/ — 183 个工作流技能和领域知识
|
||||
commands/ — 79 个斜杠命令
|
||||
hooks/ — 基于触发的自动化
|
||||
rules/ — 始终遵循的指导方针(通用 + 每种语言)
|
||||
|
||||
+10
-10
@@ -161,7 +161,7 @@
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Install plugin
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
### 步骤 2:安装规则(必需)
|
||||
@@ -206,10 +206,10 @@ npx ecc-install typescript
|
||||
# /plan "Add user authentication"
|
||||
|
||||
# Check available commands
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
**搞定!** 你现在可以使用 47 个智能体、181 项技能和 79 个命令了。
|
||||
**搞定!** 你现在可以使用 48 个智能体、183 项技能和 79 个命令了。
|
||||
|
||||
***
|
||||
|
||||
@@ -585,7 +585,7 @@ Claude Code v2.1+ **会自动加载** 任何已安装插件中的 `hooks/hooks.j
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# Install the plugin
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
或者直接添加到您的 `~/.claude/settings.json`:
|
||||
@@ -601,7 +601,7 @@ Claude Code v2.1+ **会自动加载** 任何已安装插件中的 `hooks/hooks.j
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"ecc@ecc": true
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -793,7 +793,7 @@ rules/
|
||||
<summary><b>如何检查已安装的代理/命令?</b></summary>
|
||||
|
||||
```bash
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
这会显示插件中所有可用的代理、命令和技能。
|
||||
@@ -1094,9 +1094,9 @@ opencode
|
||||
|
||||
| 功能特性 | Claude Code | OpenCode | 状态 |
|
||||
|---------|-------------|----------|--------|
|
||||
| 智能体 | PASS: 47 个 | PASS: 12 个 | **Claude Code 领先** |
|
||||
| 智能体 | PASS: 48 个 | PASS: 12 个 | **Claude Code 领先** |
|
||||
| 命令 | PASS: 79 个 | PASS: 31 个 | **Claude Code 领先** |
|
||||
| 技能 | PASS: 181 项 | PASS: 37 项 | **Claude Code 领先** |
|
||||
| 技能 | PASS: 183 项 | PASS: 37 项 | **Claude Code 领先** |
|
||||
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
|
||||
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
|
||||
| MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** |
|
||||
@@ -1206,9 +1206,9 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
|
||||
|
||||
| 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|
||||
|---------|------------|------------|-----------|----------|
|
||||
| **智能体** | 47 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
||||
| **智能体** | 48 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
||||
| **命令** | 79 | 共享 | 基于指令 | 31 |
|
||||
| **技能** | 181 | 共享 | 10 (原生格式) | 37 |
|
||||
| **技能** | 183 | 共享 | 10 (原生格式) | 37 |
|
||||
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
|
||||
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |
|
||||
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
| **Git 推送提醒器** | `Bash` | 在 `git push` 前提醒检查变更 | 0 (警告) |
|
||||
| **文档文件警告器** | `Write` | 对非标准 `.md`/`.txt` 文件发出警告(允许 README、CLAUDE、CONTRIBUTING、CHANGELOG、LICENSE、SKILL、docs/、skills/);跨平台路径处理 | 0 (警告) |
|
||||
| **策略性压缩提醒器** | `Edit\|Write` | 建议在逻辑间隔(约每 50 次工具调用)手动执行 `/compact` | 0 (警告) |
|
||||
|
||||
### PostToolUse 钩子
|
||||
|
||||
| 钩子 | 匹配器 | 功能 |
|
||||
|
||||
@@ -19,7 +19,7 @@ origin: ECC
|
||||
|
||||
此技能必须在激活前对 Claude Code 可访问。有两种引导方式:
|
||||
|
||||
1. **通过插件**: `/plugin install ecc@ecc` — 插件会自动加载此技能
|
||||
1. **通过插件**: `/plugin install everything-claude-code@everything-claude-code` — 插件会自动加载此技能
|
||||
2. **手动**: 仅将此技能复制到 `~/.claude/skills/configure-ecc/SKILL.md`,然后通过说 "configure ecc" 激活
|
||||
|
||||
***
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# 安裝外掛程式
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
### 第二步:安裝規則(必需)
|
||||
@@ -95,7 +95,7 @@ cp -r everything-claude-code/rules/* ~/.claude/rules/
|
||||
# /plan "新增使用者認證"
|
||||
|
||||
# 查看可用指令
|
||||
/plugin list ecc@ecc
|
||||
/plugin list everything-claude-code@everything-claude-code
|
||||
```
|
||||
|
||||
**完成!** 您現在使用 15+ 代理程式、30+ 技能和 20+ 指令。
|
||||
@@ -270,7 +270,7 @@ everything-claude-code/
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
|
||||
# 安裝外掛程式
|
||||
/plugin install ecc@ecc
|
||||
/plugin install everything-claude-code
|
||||
```
|
||||
|
||||
或直接新增到您的 `~/.claude/settings.json`:
|
||||
@@ -286,7 +286,7 @@ everything-claude-code/
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"ecc@ecc": true
|
||||
"everything-claude-code@everything-claude-code": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -318,7 +318,9 @@ cp -r everything-claude-code/skills/* ~/.claude/skills/
|
||||
|
||||
#### 將鉤子新增到 settings.json
|
||||
|
||||
將 `hooks/hooks.json` 中的鉤子複製到您的 `~/.claude/settings.json`。
|
||||
僅在手動安裝時,才將 `hooks/hooks.json` 中的鉤子複製到您的 `~/.claude/settings.json`。
|
||||
|
||||
如果您是透過 `/plugin install` 安裝 ECC,請不要再把這些鉤子複製到 `settings.json`。Claude Code v2.1+ 會自動載入外掛中的 `hooks/hooks.json`,重複註冊會導致重複執行以及 `${CLAUDE_PLUGIN_ROOT}` 無法解析。
|
||||
|
||||
#### 設定 MCP
|
||||
|
||||
|
||||
Generated
+178
@@ -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"
|
||||
@@ -1236,6 +1286,15 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.6.0+3.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.112"
|
||||
@@ -1244,6 +1303,7 @@ checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
@@ -1610,6 +1670,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 +1733,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 +1901,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 +1968,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 +2327,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 +2517,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 +2688,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 +2946,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"
|
||||
|
||||
+9
-1
@@ -7,6 +7,10 @@ license = "MIT"
|
||||
authors = ["Affaan Mustafa <me@affaanmustafa.com>"]
|
||||
repository = "https://github.com/affaan-m/everything-claude-code"
|
||||
|
||||
[features]
|
||||
default = ["vendored-openssl"]
|
||||
vendored-openssl = ["git2/vendored-openssl"]
|
||||
|
||||
[dependencies]
|
||||
# TUI
|
||||
ratatui = { version = "0.30", features = ["crossterm_0_28"] }
|
||||
@@ -19,12 +23,15 @@ tokio = { version = "1", features = ["full"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
|
||||
# Git integration
|
||||
git2 = "0.20"
|
||||
git2 = { version = "0.20", features = ["ssh"] }
|
||||
|
||||
# Serialization
|
||||
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 +47,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 {
|
||||
|
||||
+1625
-11
File diff suppressed because it is too large
Load Diff
+11724
-137
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);
|
||||
|
||||
+431
-69
@@ -22,17 +22,35 @@ 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}");
|
||||
}
|
||||
|
||||
if let Err(e) = maybe_auto_merge_ready_worktrees(&db, &cfg).await {
|
||||
tracing::error!("Worktree auto-merge pass failed: {e}");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -74,38 +92,52 @@ 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, || {
|
||||
manager::auto_dispatch_backlog(
|
||||
db,
|
||||
cfg,
|
||||
&cfg.default_agent,
|
||||
true,
|
||||
cfg.max_parallel_sessions,
|
||||
)
|
||||
}, |routed, deferred, leads| db.record_daemon_dispatch_pass(routed, deferred, leads))
|
||||
let summary = maybe_auto_dispatch_with_recorder(
|
||||
cfg,
|
||||
|| {
|
||||
manager::auto_dispatch_backlog(
|
||||
db,
|
||||
cfg,
|
||||
&cfg.default_agent,
|
||||
true,
|
||||
cfg.max_parallel_sessions,
|
||||
)
|
||||
},
|
||||
|routed, deferred, leads| db.record_daemon_dispatch_pass(routed, deferred, leads),
|
||||
)
|
||||
.await?;
|
||||
Ok(summary.routed)
|
||||
}
|
||||
@@ -116,26 +148,34 @@ async fn coordinate_backlog_cycle(db: &StateStore, cfg: &Config) -> Result<()> {
|
||||
cfg,
|
||||
&activity,
|
||||
|| {
|
||||
maybe_auto_dispatch_with_recorder(cfg, || {
|
||||
manager::auto_dispatch_backlog(
|
||||
db,
|
||||
cfg,
|
||||
&cfg.default_agent,
|
||||
true,
|
||||
cfg.max_parallel_sessions,
|
||||
)
|
||||
}, |routed, deferred, leads| db.record_daemon_dispatch_pass(routed, deferred, leads))
|
||||
maybe_auto_dispatch_with_recorder(
|
||||
cfg,
|
||||
|| {
|
||||
manager::auto_dispatch_backlog(
|
||||
db,
|
||||
cfg,
|
||||
&cfg.default_agent,
|
||||
true,
|
||||
cfg.max_parallel_sessions,
|
||||
)
|
||||
},
|
||||
|routed, deferred, leads| db.record_daemon_dispatch_pass(routed, deferred, leads),
|
||||
)
|
||||
},
|
||||
|| {
|
||||
maybe_auto_rebalance_with_recorder(cfg, || {
|
||||
manager::rebalance_all_teams(
|
||||
db,
|
||||
cfg,
|
||||
&cfg.default_agent,
|
||||
true,
|
||||
cfg.max_parallel_sessions,
|
||||
)
|
||||
}, |rerouted, leads| db.record_daemon_rebalance_pass(rerouted, leads))
|
||||
maybe_auto_rebalance_with_recorder(
|
||||
cfg,
|
||||
|| {
|
||||
manager::rebalance_all_teams(
|
||||
db,
|
||||
cfg,
|
||||
&cfg.default_agent,
|
||||
true,
|
||||
cfg.max_parallel_sessions,
|
||||
)
|
||||
},
|
||||
|rerouted, leads| db.record_daemon_rebalance_pass(rerouted, leads),
|
||||
)
|
||||
},
|
||||
|routed, leads| db.record_daemon_recovery_dispatch_pass(routed, leads),
|
||||
)
|
||||
@@ -163,7 +203,11 @@ where
|
||||
tracing::warn!(
|
||||
"Skipping immediate dispatch retry because chronic saturation cooloff is active"
|
||||
);
|
||||
return Ok((DispatchPassSummary::default(), rebalanced, DispatchPassSummary::default()));
|
||||
return Ok((
|
||||
DispatchPassSummary::default(),
|
||||
rebalanced,
|
||||
DispatchPassSummary::default(),
|
||||
));
|
||||
}
|
||||
let first_dispatch = dispatch().await?;
|
||||
if first_dispatch.routed > 0 {
|
||||
@@ -206,7 +250,11 @@ where
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = Result<Vec<manager::LeadDispatchOutcome>>>,
|
||||
{
|
||||
Ok(maybe_auto_dispatch_with_recorder(cfg, dispatch, |_, _, _| Ok(())).await?.routed)
|
||||
Ok(
|
||||
maybe_auto_dispatch_with_recorder(cfg, dispatch, |_, _, _| Ok(()))
|
||||
.await?
|
||||
.routed,
|
||||
)
|
||||
}
|
||||
|
||||
async fn maybe_auto_dispatch_with_recorder<F, Fut, R>(
|
||||
@@ -254,9 +302,7 @@ where
|
||||
);
|
||||
}
|
||||
if deferred > 0 {
|
||||
tracing::warn!(
|
||||
"Deferred {deferred} task handoff(s) because delegate teams were saturated"
|
||||
);
|
||||
tracing::warn!("Deferred {deferred} task handoff(s) because delegate teams were saturated");
|
||||
}
|
||||
|
||||
Ok(DispatchPassSummary {
|
||||
@@ -267,15 +313,19 @@ where
|
||||
}
|
||||
|
||||
async fn maybe_auto_rebalance(db: &StateStore, cfg: &Config) -> Result<usize> {
|
||||
maybe_auto_rebalance_with_recorder(cfg, || {
|
||||
manager::rebalance_all_teams(
|
||||
db,
|
||||
cfg,
|
||||
&cfg.default_agent,
|
||||
true,
|
||||
cfg.max_parallel_sessions,
|
||||
)
|
||||
}, |rerouted, leads| db.record_daemon_rebalance_pass(rerouted, leads))
|
||||
maybe_auto_rebalance_with_recorder(
|
||||
cfg,
|
||||
|| {
|
||||
manager::rebalance_all_teams(
|
||||
db,
|
||||
cfg,
|
||||
&cfg.default_agent,
|
||||
true,
|
||||
cfg.max_parallel_sessions,
|
||||
)
|
||||
},
|
||||
|rerouted, leads| db.record_daemon_rebalance_pass(rerouted, leads),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -315,6 +365,113 @@ where
|
||||
Ok(rerouted)
|
||||
}
|
||||
|
||||
async fn maybe_auto_merge_ready_worktrees(db: &StateStore, cfg: &Config) -> Result<usize> {
|
||||
maybe_auto_merge_ready_worktrees_with_recorder(
|
||||
cfg,
|
||||
|| manager::merge_ready_worktrees(db, true),
|
||||
|merged, active, conflicted, dirty, failed| {
|
||||
db.record_daemon_auto_merge_pass(merged, active, conflicted, dirty, failed)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn maybe_auto_merge_ready_worktrees_with<F, Fut>(cfg: &Config, merge: F) -> Result<usize>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = Result<manager::WorktreeBulkMergeOutcome>>,
|
||||
{
|
||||
maybe_auto_merge_ready_worktrees_with_recorder(cfg, merge, |_, _, _, _, _| Ok(())).await
|
||||
}
|
||||
|
||||
async fn maybe_auto_merge_ready_worktrees_with_recorder<F, Fut, R>(
|
||||
cfg: &Config,
|
||||
merge: F,
|
||||
mut record: R,
|
||||
) -> Result<usize>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = Result<manager::WorktreeBulkMergeOutcome>>,
|
||||
R: FnMut(usize, usize, usize, usize, usize) -> Result<()>,
|
||||
{
|
||||
if !cfg.auto_merge_ready_worktrees {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let outcome = merge().await?;
|
||||
let merged = outcome.merged.len();
|
||||
let active = outcome.active_with_worktree_ids.len();
|
||||
let conflicted = outcome.conflicted_session_ids.len();
|
||||
let dirty = outcome.dirty_worktree_ids.len();
|
||||
let failed = outcome.failures.len();
|
||||
record(merged, active, conflicted, dirty, failed)?;
|
||||
|
||||
if merged > 0 {
|
||||
tracing::info!("Auto-merged {merged} ready worktree(s)");
|
||||
}
|
||||
if conflicted > 0 {
|
||||
tracing::warn!(
|
||||
"Skipped {} conflicted worktree(s) during auto-merge",
|
||||
conflicted
|
||||
);
|
||||
}
|
||||
if dirty > 0 {
|
||||
tracing::warn!("Skipped {} dirty worktree(s) during auto-merge", dirty);
|
||||
}
|
||||
if active > 0 {
|
||||
tracing::info!("Skipped {active} active worktree(s) during auto-merge");
|
||||
}
|
||||
if failed > 0 {
|
||||
tracing::warn!("Auto-merge failed for {failed} worktree(s)");
|
||||
}
|
||||
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
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, cfg),
|
||||
|pruned, active| db.record_daemon_auto_prune_pass(pruned, active),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn maybe_auto_prune_inactive_worktrees_with<F, Fut>(prune: F) -> Result<usize>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = Result<manager::WorktreePruneOutcome>>,
|
||||
{
|
||||
maybe_auto_prune_inactive_worktrees_with_recorder(prune, |_, _| Ok(())).await
|
||||
}
|
||||
|
||||
async fn maybe_auto_prune_inactive_worktrees_with_recorder<F, Fut, R>(
|
||||
prune: F,
|
||||
mut record: R,
|
||||
) -> Result<usize>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = Result<manager::WorktreePruneOutcome>>,
|
||||
R: FnMut(usize, usize) -> Result<()>,
|
||||
{
|
||||
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 {
|
||||
tracing::info!("Auto-pruned {pruned} inactive worktree(s)");
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn pid_is_alive(pid: u32) -> bool {
|
||||
if pid == 0 {
|
||||
@@ -358,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,
|
||||
@@ -365,6 +524,7 @@ mod tests {
|
||||
worktree: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_heartbeat_at: now,
|
||||
metrics: SessionMetrics::default(),
|
||||
}
|
||||
}
|
||||
@@ -528,7 +688,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coordinate_backlog_cycle_retries_after_rebalance_when_dispatch_deferred() -> Result<()> {
|
||||
async fn coordinate_backlog_cycle_retries_after_rebalance_when_dispatch_deferred() -> Result<()>
|
||||
{
|
||||
let cfg = Config {
|
||||
auto_dispatch_unread_handoffs: true,
|
||||
..Config::default()
|
||||
@@ -607,7 +768,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coordinate_backlog_cycle_records_recovery_dispatch_when_it_routes_work() -> Result<()> {
|
||||
async fn coordinate_backlog_cycle_records_recovery_dispatch_when_it_routes_work() -> Result<()>
|
||||
{
|
||||
let cfg = Config {
|
||||
auto_dispatch_unread_handoffs: true,
|
||||
..Config::default()
|
||||
@@ -653,7 +815,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coordinate_backlog_cycle_rebalances_first_after_unrecovered_deferred_pressure() -> Result<()> {
|
||||
async fn coordinate_backlog_cycle_rebalances_first_after_unrecovered_deferred_pressure(
|
||||
) -> Result<()> {
|
||||
let cfg = Config {
|
||||
auto_dispatch_unread_handoffs: true,
|
||||
..Config::default()
|
||||
@@ -664,12 +827,22 @@ mod tests {
|
||||
last_dispatch_routed: 0,
|
||||
last_dispatch_deferred: 2,
|
||||
last_dispatch_leads: 1,
|
||||
chronic_saturation_streak: 1,
|
||||
last_recovery_dispatch_at: None,
|
||||
last_recovery_dispatch_routed: 0,
|
||||
last_recovery_dispatch_leads: 0,
|
||||
last_rebalance_at: None,
|
||||
last_rebalance_rerouted: 0,
|
||||
last_rebalance_leads: 0,
|
||||
last_auto_merge_at: None,
|
||||
last_auto_merge_merged: 0,
|
||||
last_auto_merge_active_skipped: 0,
|
||||
last_auto_merge_conflicted_skipped: 0,
|
||||
last_auto_merge_dirty_skipped: 0,
|
||||
last_auto_merge_failed: 0,
|
||||
last_auto_prune_at: None,
|
||||
last_auto_prune_pruned: 0,
|
||||
last_auto_prune_active_skipped: 0,
|
||||
};
|
||||
let order = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let dispatch_order = order.clone();
|
||||
@@ -708,7 +881,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coordinate_backlog_cycle_records_recovery_when_rebalance_first_dispatch_routes_work() -> Result<()> {
|
||||
async fn coordinate_backlog_cycle_records_recovery_when_rebalance_first_dispatch_routes_work(
|
||||
) -> Result<()> {
|
||||
let cfg = Config {
|
||||
auto_dispatch_unread_handoffs: true,
|
||||
..Config::default()
|
||||
@@ -719,12 +893,22 @@ mod tests {
|
||||
last_dispatch_routed: 0,
|
||||
last_dispatch_deferred: 2,
|
||||
last_dispatch_leads: 1,
|
||||
chronic_saturation_streak: 1,
|
||||
last_recovery_dispatch_at: None,
|
||||
last_recovery_dispatch_routed: 0,
|
||||
last_recovery_dispatch_leads: 0,
|
||||
last_rebalance_at: None,
|
||||
last_rebalance_rerouted: 0,
|
||||
last_rebalance_leads: 0,
|
||||
last_auto_merge_at: None,
|
||||
last_auto_merge_merged: 0,
|
||||
last_auto_merge_active_skipped: 0,
|
||||
last_auto_merge_conflicted_skipped: 0,
|
||||
last_auto_merge_dirty_skipped: 0,
|
||||
last_auto_merge_failed: 0,
|
||||
last_auto_prune_at: None,
|
||||
last_auto_prune_pruned: 0,
|
||||
last_auto_prune_active_skipped: 0,
|
||||
};
|
||||
let recorded = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let recorded_clone = recorded.clone();
|
||||
@@ -755,7 +939,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coordinate_backlog_cycle_skips_dispatch_during_chronic_cooloff_when_rebalance_does_not_help() -> Result<()> {
|
||||
async fn coordinate_backlog_cycle_skips_dispatch_during_chronic_cooloff_when_rebalance_does_not_help(
|
||||
) -> Result<()> {
|
||||
let cfg = Config {
|
||||
auto_dispatch_unread_handoffs: true,
|
||||
..Config::default()
|
||||
@@ -766,12 +951,22 @@ mod tests {
|
||||
last_dispatch_routed: 0,
|
||||
last_dispatch_deferred: 3,
|
||||
last_dispatch_leads: 1,
|
||||
chronic_saturation_streak: 1,
|
||||
last_recovery_dispatch_at: None,
|
||||
last_recovery_dispatch_routed: 0,
|
||||
last_recovery_dispatch_leads: 0,
|
||||
last_rebalance_at: Some(now - chrono::Duration::seconds(1)),
|
||||
last_rebalance_rerouted: 0,
|
||||
last_rebalance_leads: 1,
|
||||
last_auto_merge_at: None,
|
||||
last_auto_merge_merged: 0,
|
||||
last_auto_merge_active_skipped: 0,
|
||||
last_auto_merge_conflicted_skipped: 0,
|
||||
last_auto_merge_dirty_skipped: 0,
|
||||
last_auto_merge_failed: 0,
|
||||
last_auto_prune_at: None,
|
||||
last_auto_prune_pruned: 0,
|
||||
last_auto_prune_active_skipped: 0,
|
||||
};
|
||||
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
@@ -803,7 +998,67 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coordinate_backlog_cycle_skips_rebalance_when_stabilized_and_dispatch_is_healthy() -> Result<()> {
|
||||
async fn coordinate_backlog_cycle_skips_dispatch_when_persistent_saturation_streak_hits_cooloff(
|
||||
) -> Result<()> {
|
||||
let cfg = Config {
|
||||
auto_dispatch_unread_handoffs: true,
|
||||
..Config::default()
|
||||
};
|
||||
let now = chrono::Utc::now();
|
||||
let activity = DaemonActivity {
|
||||
last_dispatch_at: Some(now),
|
||||
last_dispatch_routed: 0,
|
||||
last_dispatch_deferred: 1,
|
||||
last_dispatch_leads: 1,
|
||||
chronic_saturation_streak: 3,
|
||||
last_recovery_dispatch_at: None,
|
||||
last_recovery_dispatch_routed: 0,
|
||||
last_recovery_dispatch_leads: 0,
|
||||
last_rebalance_at: Some(now - chrono::Duration::seconds(1)),
|
||||
last_rebalance_rerouted: 0,
|
||||
last_rebalance_leads: 1,
|
||||
last_auto_merge_at: None,
|
||||
last_auto_merge_merged: 0,
|
||||
last_auto_merge_active_skipped: 0,
|
||||
last_auto_merge_conflicted_skipped: 0,
|
||||
last_auto_merge_dirty_skipped: 0,
|
||||
last_auto_merge_failed: 0,
|
||||
last_auto_prune_at: None,
|
||||
last_auto_prune_pruned: 0,
|
||||
last_auto_prune_active_skipped: 0,
|
||||
};
|
||||
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
|
||||
let (first, rebalanced, recovery) = coordinate_backlog_cycle_with(
|
||||
&cfg,
|
||||
&activity,
|
||||
move || {
|
||||
let calls_clone = calls_clone.clone();
|
||||
async move {
|
||||
calls_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(DispatchPassSummary {
|
||||
routed: 1,
|
||||
deferred: 0,
|
||||
leads: 1,
|
||||
})
|
||||
}
|
||||
},
|
||||
|| async move { Ok(0) },
|
||||
|_, _| Ok(()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(first, DispatchPassSummary::default());
|
||||
assert_eq!(rebalanced, 0);
|
||||
assert_eq!(recovery, DispatchPassSummary::default());
|
||||
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coordinate_backlog_cycle_skips_rebalance_when_stabilized_and_dispatch_is_healthy(
|
||||
) -> Result<()> {
|
||||
let cfg = Config {
|
||||
auto_dispatch_unread_handoffs: true,
|
||||
..Config::default()
|
||||
@@ -814,12 +1069,22 @@ mod tests {
|
||||
last_dispatch_routed: 2,
|
||||
last_dispatch_deferred: 0,
|
||||
last_dispatch_leads: 1,
|
||||
chronic_saturation_streak: 0,
|
||||
last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)),
|
||||
last_recovery_dispatch_routed: 1,
|
||||
last_recovery_dispatch_leads: 1,
|
||||
last_rebalance_at: Some(now),
|
||||
last_rebalance_rerouted: 1,
|
||||
last_rebalance_leads: 1,
|
||||
last_auto_merge_at: None,
|
||||
last_auto_merge_merged: 0,
|
||||
last_auto_merge_active_skipped: 0,
|
||||
last_auto_merge_conflicted_skipped: 0,
|
||||
last_auto_merge_dirty_skipped: 0,
|
||||
last_auto_merge_failed: 0,
|
||||
last_auto_prune_at: None,
|
||||
last_auto_prune_pruned: 0,
|
||||
last_auto_prune_active_skipped: 0,
|
||||
};
|
||||
let rebalance_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let rebalance_calls_clone = rebalance_calls.clone();
|
||||
@@ -957,4 +1222,101 @@ mod tests {
|
||||
let _ = std::fs::remove_file(path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maybe_auto_merge_ready_worktrees_noops_when_disabled() -> Result<()> {
|
||||
let mut cfg = Config::default();
|
||||
cfg.auto_merge_ready_worktrees = false;
|
||||
|
||||
let invoked = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let invoked_flag = invoked.clone();
|
||||
|
||||
let merged = maybe_auto_merge_ready_worktrees_with(&cfg, move || {
|
||||
let invoked_flag = invoked_flag.clone();
|
||||
async move {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert_eq!(merged, 0);
|
||||
assert!(!invoked.load(std::sync::atomic::Ordering::SeqCst));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maybe_auto_merge_ready_worktrees_merges_ready_worktrees_when_enabled() -> Result<()> {
|
||||
let mut cfg = Config::default();
|
||||
cfg.auto_merge_ready_worktrees = true;
|
||||
|
||||
let merged = maybe_auto_merge_ready_worktrees_with(&cfg, || async move {
|
||||
Ok(manager::WorktreeBulkMergeOutcome {
|
||||
merged: vec![
|
||||
manager::WorktreeMergeOutcome {
|
||||
session_id: "worker-a".to_string(),
|
||||
branch: "ecc/worker-a".to_string(),
|
||||
base_branch: "main".to_string(),
|
||||
already_up_to_date: false,
|
||||
cleaned_worktree: true,
|
||||
},
|
||||
manager::WorktreeMergeOutcome {
|
||||
session_id: "worker-b".to_string(),
|
||||
branch: "ecc/worker-b".to_string(),
|
||||
base_branch: "main".to_string(),
|
||||
already_up_to_date: true,
|
||||
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(),
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert_eq!(merged, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maybe_auto_prune_inactive_worktrees_records_pruned_and_active_counts() -> Result<()> {
|
||||
let recorded = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let recorded_clone = recorded.clone();
|
||||
|
||||
let pruned = maybe_auto_prune_inactive_worktrees_with_recorder(
|
||||
|| async move {
|
||||
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| {
|
||||
*recorded_clone.lock().unwrap() = Some((pruned, active));
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(pruned, 2);
|
||||
assert_eq!(*recorded.lock().unwrap(), Some((2, 1)));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+5846
-118
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
@@ -112,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());
|
||||
}
|
||||
}
|
||||
|
||||
+101
-13
@@ -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,
|
||||
@@ -70,11 +78,7 @@ impl DbWriter {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_db_writer(
|
||||
db_path: PathBuf,
|
||||
session_id: String,
|
||||
mut rx: mpsc::UnboundedReceiver<DbMessage>,
|
||||
) {
|
||||
fn run_db_writer(db_path: PathBuf, session_id: String, mut rx: mpsc::UnboundedReceiver<DbMessage>) {
|
||||
let (opened, open_error) = match StateStore::open(&db_path) {
|
||||
Ok(db) => (Some(db), None),
|
||||
Err(error) => (None, Some(error.to_string())),
|
||||
@@ -84,7 +88,9 @@ fn run_db_writer(
|
||||
match message {
|
||||
DbMessage::UpdateState { state, ack } => {
|
||||
let result = match opened.as_ref() {
|
||||
Some(db) => db.update_state(&session_id, &state).map_err(|error| error.to_string()),
|
||||
Some(db) => db
|
||||
.update_state(&session_id, &state)
|
||||
.map_err(|error| error.to_string()),
|
||||
None => Err(open_error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Failed to open state store".to_string())),
|
||||
@@ -93,7 +99,9 @@ fn run_db_writer(
|
||||
}
|
||||
DbMessage::UpdatePid { pid, ack } => {
|
||||
let result = match opened.as_ref() {
|
||||
Some(db) => db.update_pid(&session_id, pid).map_err(|error| error.to_string()),
|
||||
Some(db) => db
|
||||
.update_pid(&session_id, pid)
|
||||
.map_err(|error| error.to_string()),
|
||||
None => Err(open_error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Failed to open state store".to_string())),
|
||||
@@ -111,6 +119,17 @@ fn run_db_writer(
|
||||
};
|
||||
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??;
|
||||
|
||||
@@ -205,9 +240,7 @@ where
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
db_writer
|
||||
.append_output_line(stream, line.clone())
|
||||
.await?;
|
||||
db_writer.append_output_line(stream, line.clone()).await?;
|
||||
output_store.push_line(&session_id, stream, line);
|
||||
}
|
||||
|
||||
@@ -239,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,
|
||||
@@ -246,6 +281,7 @@ mod tests {
|
||||
worktree: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_heartbeat_at: now,
|
||||
metrics: SessionMetrics::default(),
|
||||
})?;
|
||||
|
||||
@@ -256,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());
|
||||
|
||||
@@ -288,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(())
|
||||
}
|
||||
}
|
||||
|
||||
+5232
-90
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,19 +78,62 @@ 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(),
|
||||
(_, KeyCode::Char(',')) => dashboard.adjust_auto_dispatch_limit(-1),
|
||||
(_, KeyCode::Char('.')) => dashboard.adjust_auto_dispatch_limit(1),
|
||||
(_, KeyCode::Char('s')) => dashboard.stop_selected().await,
|
||||
(_, KeyCode::Char('u')) => dashboard.resume_selected().await,
|
||||
(_, KeyCode::Char('x')) => dashboard.cleanup_selected_worktree().await,
|
||||
(_, KeyCode::Char('X')) => dashboard.prune_inactive_worktrees().await,
|
||||
(_, KeyCode::Char('d')) => dashboard.delete_selected_session().await,
|
||||
(_, KeyCode::Char('r')) => dashboard.refresh(),
|
||||
(_, KeyCode::Char('?')) => dashboard.toggle_help(),
|
||||
|
||||
+11990
-253
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);
|
||||
|
||||
|
||||
+2470
-13
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,913 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ECC Dashboard - Everything Claude Code GUI
|
||||
Cross-platform TkInter application for managing ECC components
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, scrolledtext, messagebox
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from scripts.lib.ecc_dashboard_runtime import build_terminal_launch, maximize_window
|
||||
|
||||
# ============================================================================
|
||||
# DATA LOADERS - Load ECC data from the project
|
||||
# ============================================================================
|
||||
|
||||
def get_project_path() -> str:
|
||||
"""Get the ECC project path - assumes this script is run from the project dir"""
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def load_agents(project_path: str) -> List[Dict]:
|
||||
"""Load agents from AGENTS.md"""
|
||||
agents_file = os.path.join(project_path, "AGENTS.md")
|
||||
agents = []
|
||||
|
||||
if os.path.exists(agents_file):
|
||||
with open(agents_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse agent table from AGENTS.md
|
||||
lines = content.split('\n')
|
||||
in_table = False
|
||||
for line in lines:
|
||||
if '| Agent | Purpose | When to Use |' in line:
|
||||
in_table = True
|
||||
continue
|
||||
if in_table and line.startswith('|'):
|
||||
parts = [p.strip() for p in line.split('|')]
|
||||
if len(parts) >= 4 and parts[1] and parts[1] != 'Agent':
|
||||
agents.append({
|
||||
'name': parts[1],
|
||||
'purpose': parts[2],
|
||||
'when_to_use': parts[3]
|
||||
})
|
||||
|
||||
# Fallback default agents if file not found
|
||||
if not agents:
|
||||
agents = [
|
||||
{'name': 'planner', 'purpose': 'Implementation planning', 'when_to_use': 'Complex features, refactoring'},
|
||||
{'name': 'architect', 'purpose': 'System design and scalability', 'when_to_use': 'Architectural decisions'},
|
||||
{'name': 'tdd-guide', 'purpose': 'Test-driven development', 'when_to_use': 'New features, bug fixes'},
|
||||
{'name': 'code-reviewer', 'purpose': 'Code quality and maintainability', 'when_to_use': 'After writing/modifying code'},
|
||||
{'name': 'security-reviewer', 'purpose': 'Vulnerability detection', 'when_to_use': 'Before commits, sensitive code'},
|
||||
{'name': 'build-error-resolver', 'purpose': 'Fix build/type errors', 'when_to_use': 'When build fails'},
|
||||
{'name': 'e2e-runner', 'purpose': 'End-to-end Playwright testing', 'when_to_use': 'Critical user flows'},
|
||||
{'name': 'refactor-cleaner', 'purpose': 'Dead code cleanup', 'when_to_use': 'Code maintenance'},
|
||||
{'name': 'doc-updater', 'purpose': 'Documentation and codemaps', 'when_to_use': 'Updating docs'},
|
||||
{'name': 'go-reviewer', 'purpose': 'Go code review', 'when_to_use': 'Go projects'},
|
||||
{'name': 'python-reviewer', 'purpose': 'Python code review', 'when_to_use': 'Python projects'},
|
||||
{'name': 'typescript-reviewer', 'purpose': 'TypeScript/JavaScript code review', 'when_to_use': 'TypeScript projects'},
|
||||
{'name': 'rust-reviewer', 'purpose': 'Rust code review', 'when_to_use': 'Rust projects'},
|
||||
{'name': 'java-reviewer', 'purpose': 'Java and Spring Boot code review', 'when_to_use': 'Java projects'},
|
||||
{'name': 'kotlin-reviewer', 'purpose': 'Kotlin code review', 'when_to_use': 'Kotlin projects'},
|
||||
{'name': 'cpp-reviewer', 'purpose': 'C/C++ code review', 'when_to_use': 'C/C++ projects'},
|
||||
{'name': 'database-reviewer', 'purpose': 'PostgreSQL/Supabase specialist', 'when_to_use': 'Database work'},
|
||||
{'name': 'loop-operator', 'purpose': 'Autonomous loop execution', 'when_to_use': 'Run loops safely'},
|
||||
{'name': 'harness-optimizer', 'purpose': 'Harness config tuning', 'when_to_use': 'Reliability, cost, throughput'},
|
||||
]
|
||||
|
||||
return agents
|
||||
|
||||
def load_skills(project_path: str) -> List[Dict]:
|
||||
"""Load skills from skills directory"""
|
||||
skills_dir = os.path.join(project_path, "skills")
|
||||
skills = []
|
||||
|
||||
if os.path.exists(skills_dir):
|
||||
for item in os.listdir(skills_dir):
|
||||
skill_path = os.path.join(skills_dir, item)
|
||||
if os.path.isdir(skill_path):
|
||||
skill_file = os.path.join(skill_path, "SKILL.md")
|
||||
description = item.replace('-', ' ').title()
|
||||
|
||||
if os.path.exists(skill_file):
|
||||
try:
|
||||
with open(skill_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
# Extract description from first lines
|
||||
lines = content.split('\n')
|
||||
for line in lines:
|
||||
if line.strip() and not line.startswith('#'):
|
||||
description = line.strip()[:100]
|
||||
break
|
||||
if line.startswith('# '):
|
||||
description = line[2:].strip()[:100]
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
# Determine category
|
||||
category = "General"
|
||||
item_lower = item.lower()
|
||||
if 'python' in item_lower or 'django' in item_lower:
|
||||
category = "Python"
|
||||
elif 'golang' in item_lower or 'go-' in item_lower:
|
||||
category = "Go"
|
||||
elif 'frontend' in item_lower or 'react' in item_lower:
|
||||
category = "Frontend"
|
||||
elif 'backend' in item_lower or 'api' in item_lower:
|
||||
category = "Backend"
|
||||
elif 'security' in item_lower:
|
||||
category = "Security"
|
||||
elif 'testing' in item_lower or 'tdd' in item_lower:
|
||||
category = "Testing"
|
||||
elif 'docker' in item_lower or 'deployment' in item_lower:
|
||||
category = "DevOps"
|
||||
elif 'swift' in item_lower or 'ios' in item_lower:
|
||||
category = "iOS"
|
||||
elif 'java' in item_lower or 'spring' in item_lower:
|
||||
category = "Java"
|
||||
elif 'rust' in item_lower:
|
||||
category = "Rust"
|
||||
|
||||
skills.append({
|
||||
'name': item,
|
||||
'description': description,
|
||||
'category': category,
|
||||
'path': skill_path
|
||||
})
|
||||
|
||||
# Fallback if directory doesn't exist
|
||||
if not skills:
|
||||
skills = [
|
||||
{'name': 'tdd-workflow', 'description': 'Test-driven development workflow', 'category': 'Testing'},
|
||||
{'name': 'coding-standards', 'description': 'Baseline coding conventions', 'category': 'General'},
|
||||
{'name': 'security-review', 'description': 'Security checklist and patterns', 'category': 'Security'},
|
||||
{'name': 'frontend-patterns', 'description': 'React and Next.js patterns', 'category': 'Frontend'},
|
||||
{'name': 'backend-patterns', 'description': 'API and database patterns', 'category': 'Backend'},
|
||||
{'name': 'api-design', 'description': 'REST API design patterns', 'category': 'Backend'},
|
||||
{'name': 'docker-patterns', 'description': 'Docker and container patterns', 'category': 'DevOps'},
|
||||
{'name': 'e2e-testing', 'description': 'Playwright E2E testing patterns', 'category': 'Testing'},
|
||||
{'name': 'verification-loop', 'description': 'Build, test, lint verification', 'category': 'General'},
|
||||
{'name': 'python-patterns', 'description': 'Python idioms and best practices', 'category': 'Python'},
|
||||
{'name': 'golang-patterns', 'description': 'Go idioms and best practices', 'category': 'Go'},
|
||||
{'name': 'django-patterns', 'description': 'Django patterns and best practices', 'category': 'Python'},
|
||||
{'name': 'springboot-patterns', 'description': 'Java Spring Boot patterns', 'category': 'Java'},
|
||||
{'name': 'laravel-patterns', 'description': 'Laravel architecture patterns', 'category': 'PHP'},
|
||||
]
|
||||
|
||||
return skills
|
||||
|
||||
def load_commands(project_path: str) -> List[Dict]:
|
||||
"""Load commands from commands directory"""
|
||||
commands_dir = os.path.join(project_path, "commands")
|
||||
commands = []
|
||||
|
||||
if os.path.exists(commands_dir):
|
||||
for item in os.listdir(commands_dir):
|
||||
if item.endswith('.md'):
|
||||
cmd_name = item[:-3]
|
||||
description = ""
|
||||
|
||||
try:
|
||||
with open(os.path.join(commands_dir, item), 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
lines = content.split('\n')
|
||||
for line in lines:
|
||||
if line.startswith('# '):
|
||||
description = line[2:].strip()
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
commands.append({
|
||||
'name': cmd_name,
|
||||
'description': description or cmd_name.replace('-', ' ').title()
|
||||
})
|
||||
|
||||
# Fallback commands
|
||||
if not commands:
|
||||
commands = [
|
||||
{'name': 'plan', 'description': 'Create implementation plan'},
|
||||
{'name': 'tdd', 'description': 'Test-driven development workflow'},
|
||||
{'name': 'code-review', 'description': 'Review code for quality and security'},
|
||||
{'name': 'build-fix', 'description': 'Fix build and TypeScript errors'},
|
||||
{'name': 'e2e', 'description': 'Generate and run E2E tests'},
|
||||
{'name': 'refactor-clean', 'description': 'Remove dead code'},
|
||||
{'name': 'verify', 'description': 'Run verification loop'},
|
||||
{'name': 'eval', 'description': 'Run evaluation against criteria'},
|
||||
{'name': 'security', 'description': 'Run comprehensive security review'},
|
||||
{'name': 'test-coverage', 'description': 'Analyze test coverage'},
|
||||
{'name': 'update-docs', 'description': 'Update documentation'},
|
||||
{'name': 'setup-pm', 'description': 'Configure package manager'},
|
||||
{'name': 'go-review', 'description': 'Go code review'},
|
||||
{'name': 'go-test', 'description': 'Go TDD workflow'},
|
||||
{'name': 'python-review', 'description': 'Python code review'},
|
||||
]
|
||||
|
||||
return commands
|
||||
|
||||
def load_rules(project_path: str) -> List[Dict]:
|
||||
"""Load rules from rules directory"""
|
||||
rules_dir = os.path.join(project_path, "rules")
|
||||
rules = []
|
||||
|
||||
if os.path.exists(rules_dir):
|
||||
for item in os.listdir(rules_dir):
|
||||
item_path = os.path.join(rules_dir, item)
|
||||
if os.path.isdir(item_path):
|
||||
# Common rules
|
||||
if item == "common":
|
||||
for file in os.listdir(item_path):
|
||||
if file.endswith('.md'):
|
||||
rules.append({
|
||||
'name': file[:-3],
|
||||
'language': 'Common',
|
||||
'path': os.path.join(item_path, file)
|
||||
})
|
||||
else:
|
||||
# Language-specific rules
|
||||
for file in os.listdir(item_path):
|
||||
if file.endswith('.md'):
|
||||
rules.append({
|
||||
'name': file[:-3],
|
||||
'language': item.title(),
|
||||
'path': os.path.join(item_path, file)
|
||||
})
|
||||
|
||||
# Fallback rules
|
||||
if not rules:
|
||||
rules = [
|
||||
{'name': 'coding-style', 'language': 'Common', 'path': ''},
|
||||
{'name': 'git-workflow', 'language': 'Common', 'path': ''},
|
||||
{'name': 'testing', 'language': 'Common', 'path': ''},
|
||||
{'name': 'performance', 'language': 'Common', 'path': ''},
|
||||
{'name': 'patterns', 'language': 'Common', 'path': ''},
|
||||
{'name': 'security', 'language': 'Common', 'path': ''},
|
||||
{'name': 'typescript', 'language': 'TypeScript', 'path': ''},
|
||||
{'name': 'python', 'language': 'Python', 'path': ''},
|
||||
{'name': 'golang', 'language': 'Go', 'path': ''},
|
||||
{'name': 'swift', 'language': 'Swift', 'path': ''},
|
||||
{'name': 'php', 'language': 'PHP', 'path': ''},
|
||||
]
|
||||
|
||||
return rules
|
||||
|
||||
# ============================================================================
|
||||
# MAIN APPLICATION
|
||||
# ============================================================================
|
||||
|
||||
class ECCDashboard(tk.Tk):
|
||||
"""Main ECC Dashboard Application"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.project_path = get_project_path()
|
||||
self.title("ECC Dashboard - Everything Claude Code")
|
||||
|
||||
maximize_window(self)
|
||||
|
||||
try:
|
||||
self.icon_image = tk.PhotoImage(file='assets/images/ecc-logo.png')
|
||||
self.iconphoto(True, self.icon_image)
|
||||
except:
|
||||
pass
|
||||
|
||||
self.minsize(800, 600)
|
||||
|
||||
# Load data
|
||||
self.agents = load_agents(self.project_path)
|
||||
self.skills = load_skills(self.project_path)
|
||||
self.commands = load_commands(self.project_path)
|
||||
self.rules = load_rules(self.project_path)
|
||||
|
||||
# Settings
|
||||
self.settings = {
|
||||
'project_path': self.project_path,
|
||||
'theme': 'light'
|
||||
}
|
||||
|
||||
# Setup UI
|
||||
self.setup_styles()
|
||||
self.create_widgets()
|
||||
|
||||
# Center window
|
||||
self.center_window()
|
||||
|
||||
def setup_styles(self):
|
||||
"""Setup ttk styles for modern look"""
|
||||
style = ttk.Style()
|
||||
style.theme_use('clam')
|
||||
|
||||
# Configure tab style
|
||||
style.configure('TNotebook', background='#f0f0f0')
|
||||
style.configure('TNotebook.Tab', padding=[10, 5], font=('Arial', 10))
|
||||
style.map('TNotebook.Tab', background=[('selected', '#ffffff')])
|
||||
|
||||
# Configure Treeview
|
||||
style.configure('Treeview', font=('Arial', 10), rowheight=25)
|
||||
style.configure('Treeview.Heading', font=('Arial', 10, 'bold'))
|
||||
|
||||
# Configure buttons
|
||||
style.configure('TButton', font=('Arial', 10), padding=5)
|
||||
|
||||
def center_window(self):
|
||||
"""Center the window on screen"""
|
||||
self.update_idletasks()
|
||||
width = self.winfo_width()
|
||||
height = self.winfo_height()
|
||||
x = (self.winfo_screenwidth() // 2) - (width // 2)
|
||||
y = (self.winfo_screenheight() // 2) - (height // 2)
|
||||
self.geometry(f'{width}x{height}+{x}+{y}')
|
||||
|
||||
def create_widgets(self):
|
||||
"""Create all UI widgets"""
|
||||
# Main container
|
||||
main_frame = ttk.Frame(self)
|
||||
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
||||
|
||||
# Header
|
||||
header_frame = ttk.Frame(main_frame)
|
||||
header_frame.pack(fill=tk.X, pady=(0, 10))
|
||||
|
||||
try:
|
||||
self.logo_image = tk.PhotoImage(file='assets/images/ecc-logo.png')
|
||||
self.logo_image = self.logo_image.subsample(2, 2)
|
||||
ttk.Label(header_frame, image=self.logo_image).pack(side=tk.LEFT, padx=(0, 10))
|
||||
except:
|
||||
pass
|
||||
|
||||
self.title_label = ttk.Label(header_frame, text="ECC Dashboard", font=('Open Sans', 18, 'bold'))
|
||||
self.title_label.pack(side=tk.LEFT)
|
||||
self.version_label = ttk.Label(header_frame, text="v1.10.0", font=('Open Sans', 10), foreground='gray')
|
||||
self.version_label.pack(side=tk.LEFT, padx=(10, 0))
|
||||
|
||||
# Notebook (tabs)
|
||||
self.notebook = ttk.Notebook(main_frame)
|
||||
self.notebook.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Create tabs
|
||||
self.create_agents_tab()
|
||||
self.create_skills_tab()
|
||||
self.create_commands_tab()
|
||||
self.create_rules_tab()
|
||||
self.create_settings_tab()
|
||||
|
||||
# Status bar
|
||||
status_frame = ttk.Frame(main_frame)
|
||||
status_frame.pack(fill=tk.X, pady=(10, 0))
|
||||
|
||||
self.status_label = ttk.Label(status_frame,
|
||||
text=f"Ready | Agents: {len(self.agents)} | Skills: {len(self.skills)} | Commands: {len(self.commands)}",
|
||||
font=('Arial', 9), foreground='gray')
|
||||
self.status_label.pack(side=tk.LEFT)
|
||||
|
||||
# =========================================================================
|
||||
# AGENTS TAB
|
||||
# =========================================================================
|
||||
|
||||
def create_agents_tab(self):
|
||||
"""Create Agents tab"""
|
||||
frame = ttk.Frame(self.notebook)
|
||||
self.notebook.add(frame, text=f"Agents ({len(self.agents)})")
|
||||
|
||||
# Search bar
|
||||
search_frame = ttk.Frame(frame)
|
||||
search_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
ttk.Label(search_frame, text="Search:").pack(side=tk.LEFT)
|
||||
self.agent_search = ttk.Entry(search_frame, width=30)
|
||||
self.agent_search.pack(side=tk.LEFT, padx=5)
|
||||
self.agent_search.bind('<KeyRelease>', self.filter_agents)
|
||||
|
||||
ttk.Label(search_frame, text="Count:").pack(side=tk.LEFT, padx=(20, 0))
|
||||
self.agent_count_label = ttk.Label(search_frame, text=str(len(self.agents)))
|
||||
self.agent_count_label.pack(side=tk.LEFT)
|
||||
|
||||
# Split pane: list + details
|
||||
paned = ttk.PanedWindow(frame, orient=tk.HORIZONTAL)
|
||||
paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
|
||||
|
||||
# Agent list
|
||||
list_frame = ttk.Frame(paned)
|
||||
paned.add(list_frame, weight=2)
|
||||
|
||||
columns = ('name', 'purpose')
|
||||
self.agent_tree = ttk.Treeview(list_frame, columns=columns, show='tree headings')
|
||||
self.agent_tree.heading('#0', text='#')
|
||||
self.agent_tree.heading('name', text='Agent Name')
|
||||
self.agent_tree.heading('purpose', text='Purpose')
|
||||
self.agent_tree.column('#0', width=40)
|
||||
self.agent_tree.column('name', width=180)
|
||||
self.agent_tree.column('purpose', width=250)
|
||||
|
||||
self.agent_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
# Scrollbar
|
||||
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.agent_tree.yview)
|
||||
self.agent_tree.configure(yscrollcommand=scrollbar.set)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
# Details panel
|
||||
details_frame = ttk.Frame(paned)
|
||||
paned.add(details_frame, weight=1)
|
||||
|
||||
ttk.Label(details_frame, text="Details", font=('Arial', 11, 'bold')).pack(anchor=tk.W, pady=5)
|
||||
|
||||
self.agent_details = scrolledtext.ScrolledText(details_frame, wrap=tk.WORD, height=15)
|
||||
self.agent_details.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Bind selection
|
||||
self.agent_tree.bind('<<TreeviewSelect>>', self.on_agent_select)
|
||||
|
||||
# Populate list
|
||||
self.populate_agents(self.agents)
|
||||
|
||||
def populate_agents(self, agents: List[Dict]):
|
||||
"""Populate agents list"""
|
||||
for item in self.agent_tree.get_children():
|
||||
self.agent_tree.delete(item)
|
||||
|
||||
for i, agent in enumerate(agents, 1):
|
||||
self.agent_tree.insert('', tk.END, text=str(i), values=(agent['name'], agent['purpose']))
|
||||
|
||||
def filter_agents(self, event=None):
|
||||
"""Filter agents based on search"""
|
||||
query = self.agent_search.get().lower()
|
||||
|
||||
if not query:
|
||||
filtered = self.agents
|
||||
else:
|
||||
filtered = [a for a in self.agents
|
||||
if query in a['name'].lower() or query in a['purpose'].lower()]
|
||||
|
||||
self.populate_agents(filtered)
|
||||
self.agent_count_label.config(text=str(len(filtered)))
|
||||
|
||||
def on_agent_select(self, event):
|
||||
"""Handle agent selection"""
|
||||
selection = self.agent_tree.selection()
|
||||
if not selection:
|
||||
return
|
||||
|
||||
item = self.agent_tree.item(selection[0])
|
||||
agent_name = item['values'][0]
|
||||
|
||||
agent = next((a for a in self.agents if a['name'] == agent_name), None)
|
||||
if agent:
|
||||
details = f"""Agent: {agent['name']}
|
||||
|
||||
Purpose: {agent['purpose']}
|
||||
|
||||
When to Use: {agent['when_to_use']}
|
||||
|
||||
---
|
||||
Usage in Claude Code:
|
||||
Use the /{agent['name']} command or invoke via agent delegation."""
|
||||
self.agent_details.delete('1.0', tk.END)
|
||||
self.agent_details.insert('1.0', details)
|
||||
|
||||
# =========================================================================
|
||||
# SKILLS TAB
|
||||
# =========================================================================
|
||||
|
||||
def create_skills_tab(self):
|
||||
"""Create Skills tab"""
|
||||
frame = ttk.Frame(self.notebook)
|
||||
self.notebook.add(frame, text=f"Skills ({len(self.skills)})")
|
||||
|
||||
# Search and filter
|
||||
filter_frame = ttk.Frame(frame)
|
||||
filter_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
ttk.Label(filter_frame, text="Search:").pack(side=tk.LEFT)
|
||||
self.skill_search = ttk.Entry(filter_frame, width=25)
|
||||
self.skill_search.pack(side=tk.LEFT, padx=5)
|
||||
self.skill_search.bind('<KeyRelease>', self.filter_skills)
|
||||
|
||||
ttk.Label(filter_frame, text="Category:").pack(side=tk.LEFT, padx=(20, 0))
|
||||
self.skill_category = ttk.Combobox(filter_frame, values=['All'] + self.get_categories(), width=15)
|
||||
self.skill_category.set('All')
|
||||
self.skill_category.pack(side=tk.LEFT, padx=5)
|
||||
self.skill_category.bind('<<ComboboxSelected>>', self.filter_skills)
|
||||
|
||||
ttk.Label(filter_frame, text="Count:").pack(side=tk.LEFT, padx=(20, 0))
|
||||
self.skill_count_label = ttk.Label(filter_frame, text=str(len(self.skills)))
|
||||
self.skill_count_label.pack(side=tk.LEFT)
|
||||
|
||||
# Split pane
|
||||
paned = ttk.PanedWindow(frame, orient=tk.HORIZONTAL)
|
||||
paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
|
||||
|
||||
# Skill list
|
||||
list_frame = ttk.Frame(paned)
|
||||
paned.add(list_frame, weight=1)
|
||||
|
||||
columns = ('name', 'category', 'description')
|
||||
self.skill_tree = ttk.Treeview(list_frame, columns=columns, show='tree headings')
|
||||
self.skill_tree.heading('#0', text='#')
|
||||
self.skill_tree.heading('name', text='Skill Name')
|
||||
self.skill_tree.heading('category', text='Category')
|
||||
self.skill_tree.heading('description', text='Description')
|
||||
|
||||
self.skill_tree.column('#0', width=40)
|
||||
self.skill_tree.column('name', width=180)
|
||||
self.skill_tree.column('category', width=100)
|
||||
self.skill_tree.column('description', width=300)
|
||||
|
||||
self.skill_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.skill_tree.yview)
|
||||
self.skill_tree.configure(yscrollcommand=scrollbar.set)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
# Details
|
||||
details_frame = ttk.Frame(paned)
|
||||
paned.add(details_frame, weight=1)
|
||||
|
||||
ttk.Label(details_frame, text="Description", font=('Arial', 11, 'bold')).pack(anchor=tk.W, pady=5)
|
||||
|
||||
self.skill_details = scrolledtext.ScrolledText(details_frame, wrap=tk.WORD, height=15)
|
||||
self.skill_details.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
self.skill_tree.bind('<<TreeviewSelect>>', self.on_skill_select)
|
||||
|
||||
self.populate_skills(self.skills)
|
||||
|
||||
def get_categories(self) -> List[str]:
|
||||
"""Get unique categories from skills"""
|
||||
categories = set(s['category'] for s in self.skills)
|
||||
return sorted(categories)
|
||||
|
||||
def populate_skills(self, skills: List[Dict]):
|
||||
"""Populate skills list"""
|
||||
for item in self.skill_tree.get_children():
|
||||
self.skill_tree.delete(item)
|
||||
|
||||
for i, skill in enumerate(skills, 1):
|
||||
self.skill_tree.insert('', tk.END, text=str(i),
|
||||
values=(skill['name'], skill['category'], skill['description']))
|
||||
|
||||
def filter_skills(self, event=None):
|
||||
"""Filter skills based on search and category"""
|
||||
search = self.skill_search.get().lower()
|
||||
category = self.skill_category.get()
|
||||
|
||||
filtered = self.skills
|
||||
|
||||
if category != 'All':
|
||||
filtered = [s for s in filtered if s['category'] == category]
|
||||
|
||||
if search:
|
||||
filtered = [s for s in filtered
|
||||
if search in s['name'].lower() or search in s['description'].lower()]
|
||||
|
||||
self.populate_skills(filtered)
|
||||
self.skill_count_label.config(text=str(len(filtered)))
|
||||
|
||||
def on_skill_select(self, event):
|
||||
"""Handle skill selection"""
|
||||
selection = self.skill_tree.selection()
|
||||
if not selection:
|
||||
return
|
||||
|
||||
item = self.skill_tree.item(selection[0])
|
||||
skill_name = item['values'][0]
|
||||
|
||||
skill = next((s for s in self.skills if s['name'] == skill_name), None)
|
||||
if skill:
|
||||
details = f"""Skill: {skill['name']}
|
||||
|
||||
Category: {skill['category']}
|
||||
|
||||
Description: {skill['description']}
|
||||
|
||||
Path: {skill['path']}
|
||||
|
||||
---
|
||||
Usage: This skill is automatically activated when working with related technologies."""
|
||||
self.skill_details.delete('1.0', tk.END)
|
||||
self.skill_details.insert('1.0', details)
|
||||
|
||||
# =========================================================================
|
||||
# COMMANDS TAB
|
||||
# =========================================================================
|
||||
|
||||
def create_commands_tab(self):
|
||||
"""Create Commands tab"""
|
||||
frame = ttk.Frame(self.notebook)
|
||||
self.notebook.add(frame, text=f"Commands ({len(self.commands)})")
|
||||
|
||||
# Info
|
||||
info_frame = ttk.Frame(frame)
|
||||
info_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
ttk.Label(info_frame, text="Slash Commands for Claude Code:",
|
||||
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
|
||||
ttk.Label(info_frame, text="Use these commands in Claude Code by typing /command_name",
|
||||
foreground='gray').pack(anchor=tk.W)
|
||||
|
||||
# Commands list
|
||||
list_frame = ttk.Frame(frame)
|
||||
list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
|
||||
|
||||
columns = ('name', 'description')
|
||||
self.command_tree = ttk.Treeview(list_frame, columns=columns, show='tree headings')
|
||||
self.command_tree.heading('#0', text='#')
|
||||
self.command_tree.heading('name', text='Command')
|
||||
self.command_tree.heading('description', text='Description')
|
||||
|
||||
self.command_tree.column('#0', width=40)
|
||||
self.command_tree.column('name', width=150)
|
||||
self.command_tree.column('description', width=400)
|
||||
|
||||
self.command_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.command_tree.yview)
|
||||
self.command_tree.configure(yscrollcommand=scrollbar.set)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
# Populate
|
||||
for i, cmd in enumerate(self.commands, 1):
|
||||
self.command_tree.insert('', tk.END, text=str(i),
|
||||
values=('/' + cmd['name'], cmd['description']))
|
||||
|
||||
# =========================================================================
|
||||
# RULES TAB
|
||||
# =========================================================================
|
||||
|
||||
def create_rules_tab(self):
|
||||
"""Create Rules tab"""
|
||||
frame = ttk.Frame(self.notebook)
|
||||
self.notebook.add(frame, text=f"Rules ({len(self.rules)})")
|
||||
|
||||
# Info
|
||||
info_frame = ttk.Frame(frame)
|
||||
info_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
ttk.Label(info_frame, text="Coding Rules by Language:",
|
||||
font=('Arial', 10, 'bold')).pack(anchor=tk.W)
|
||||
ttk.Label(info_frame, text="These rules are automatically applied in Claude Code",
|
||||
foreground='gray').pack(anchor=tk.W)
|
||||
|
||||
# Filter
|
||||
filter_frame = ttk.Frame(frame)
|
||||
filter_frame.pack(fill=tk.X, padx=10, pady=5)
|
||||
|
||||
ttk.Label(filter_frame, text="Language:").pack(side=tk.LEFT)
|
||||
self.rules_language = ttk.Combobox(filter_frame,
|
||||
values=['All'] + self.get_rule_languages(),
|
||||
width=15)
|
||||
self.rules_language.set('All')
|
||||
self.rules_language.pack(side=tk.LEFT, padx=5)
|
||||
self.rules_language.bind('<<ComboboxSelected>>', self.filter_rules)
|
||||
|
||||
# Rules list
|
||||
list_frame = ttk.Frame(frame)
|
||||
list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
|
||||
|
||||
columns = ('name', 'language')
|
||||
self.rules_tree = ttk.Treeview(list_frame, columns=columns, show='tree headings')
|
||||
self.rules_tree.heading('#0', text='#')
|
||||
self.rules_tree.heading('name', text='Rule Name')
|
||||
self.rules_tree.heading('language', text='Language')
|
||||
|
||||
self.rules_tree.column('#0', width=40)
|
||||
self.rules_tree.column('name', width=250)
|
||||
self.rules_tree.column('language', width=100)
|
||||
|
||||
self.rules_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.rules_tree.yview)
|
||||
self.rules_tree.configure(yscrollcommand=scrollbar.set)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
self.populate_rules(self.rules)
|
||||
|
||||
def get_rule_languages(self) -> List[str]:
|
||||
"""Get unique languages from rules"""
|
||||
languages = set(r['language'] for r in self.rules)
|
||||
return sorted(languages)
|
||||
|
||||
def populate_rules(self, rules: List[Dict]):
|
||||
"""Populate rules list"""
|
||||
for item in self.rules_tree.get_children():
|
||||
self.rules_tree.delete(item)
|
||||
|
||||
for i, rule in enumerate(rules, 1):
|
||||
self.rules_tree.insert('', tk.END, text=str(i),
|
||||
values=(rule['name'], rule['language']))
|
||||
|
||||
def filter_rules(self, event=None):
|
||||
"""Filter rules by language"""
|
||||
language = self.rules_language.get()
|
||||
|
||||
if language == 'All':
|
||||
filtered = self.rules
|
||||
else:
|
||||
filtered = [r for r in self.rules if r['language'] == language]
|
||||
|
||||
self.populate_rules(filtered)
|
||||
|
||||
# =========================================================================
|
||||
# SETTINGS TAB
|
||||
# =========================================================================
|
||||
|
||||
def create_settings_tab(self):
|
||||
"""Create Settings tab"""
|
||||
frame = ttk.Frame(self.notebook)
|
||||
self.notebook.add(frame, text="Settings")
|
||||
|
||||
# Project path
|
||||
path_frame = ttk.LabelFrame(frame, text="Project Path", padding=10)
|
||||
path_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
self.path_entry = ttk.Entry(path_frame, width=60)
|
||||
self.path_entry.insert(0, self.project_path)
|
||||
self.path_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
|
||||
ttk.Button(path_frame, text="Browse...", command=self.browse_path).pack(side=tk.LEFT, padx=5)
|
||||
|
||||
# Theme
|
||||
theme_frame = ttk.LabelFrame(frame, text="Appearance", padding=10)
|
||||
theme_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
ttk.Label(theme_frame, text="Theme:").pack(anchor=tk.W)
|
||||
self.theme_var = tk.StringVar(value='light')
|
||||
light_rb = ttk.Radiobutton(theme_frame, text="Light", variable=self.theme_var,
|
||||
value='light', command=self.apply_theme)
|
||||
light_rb.pack(anchor=tk.W)
|
||||
dark_rb = ttk.Radiobutton(theme_frame, text="Dark", variable=self.theme_var,
|
||||
value='dark', command=self.apply_theme)
|
||||
dark_rb.pack(anchor=tk.W)
|
||||
|
||||
font_frame = ttk.LabelFrame(frame, text="Font", padding=10)
|
||||
font_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
ttk.Label(font_frame, text="Font Family:").pack(anchor=tk.W)
|
||||
self.font_var = tk.StringVar(value='Open Sans')
|
||||
|
||||
fonts = ['Open Sans', 'Arial', 'Helvetica', 'Times New Roman', 'Courier New', 'Verdana', 'Georgia', 'Tahoma', 'Trebuchet MS']
|
||||
self.font_combo = ttk.Combobox(font_frame, textvariable=self.font_var, values=fonts, state='readonly')
|
||||
self.font_combo.pack(anchor=tk.W, fill=tk.X, pady=(5, 0))
|
||||
self.font_combo.bind('<<ComboboxSelected>>', lambda e: self.apply_theme())
|
||||
|
||||
ttk.Label(font_frame, text="Font Size:").pack(anchor=tk.W, pady=(10, 0))
|
||||
self.size_var = tk.StringVar(value='10')
|
||||
sizes = ['8', '9', '10', '11', '12', '14', '16', '18', '20']
|
||||
self.size_combo = ttk.Combobox(font_frame, textvariable=self.size_var, values=sizes, state='readonly', width=10)
|
||||
self.size_combo.pack(anchor=tk.W, pady=(5, 0))
|
||||
self.size_combo.bind('<<ComboboxSelected>>', lambda e: self.apply_theme())
|
||||
|
||||
# Quick Actions
|
||||
actions_frame = ttk.LabelFrame(frame, text="Quick Actions", padding=10)
|
||||
actions_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
||||
|
||||
ttk.Button(actions_frame, text="Open Project in Terminal",
|
||||
command=self.open_terminal).pack(fill=tk.X, pady=2)
|
||||
ttk.Button(actions_frame, text="Open README",
|
||||
command=self.open_readme).pack(fill=tk.X, pady=2)
|
||||
ttk.Button(actions_frame, text="Open AGENTS.md",
|
||||
command=self.open_agents).pack(fill=tk.X, pady=2)
|
||||
ttk.Button(actions_frame, text="Refresh Data",
|
||||
command=self.refresh_data).pack(fill=tk.X, pady=2)
|
||||
|
||||
# About
|
||||
about_frame = ttk.LabelFrame(frame, text="About", padding=10)
|
||||
about_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
|
||||
about_text = """ECC Dashboard v1.0.0
|
||||
Everything Claude Code GUI
|
||||
|
||||
A cross-platform desktop application for
|
||||
managing and exploring ECC components.
|
||||
|
||||
Version: 1.10.0
|
||||
Project: github.com/affaan-m/everything-claude-code"""
|
||||
|
||||
ttk.Label(about_frame, text=about_text, justify=tk.LEFT).pack(anchor=tk.W)
|
||||
|
||||
def browse_path(self):
|
||||
"""Browse for project path"""
|
||||
from tkinter import filedialog
|
||||
path = filedialog.askdirectory(initialdir=self.project_path)
|
||||
if path:
|
||||
self.path_entry.delete(0, tk.END)
|
||||
self.path_entry.insert(0, path)
|
||||
|
||||
def open_terminal(self):
|
||||
"""Open terminal at project path"""
|
||||
path = self.path_entry.get()
|
||||
argv, kwargs = build_terminal_launch(path)
|
||||
subprocess.Popen(argv, **kwargs)
|
||||
|
||||
def open_readme(self):
|
||||
"""Open README in default browser/reader"""
|
||||
import subprocess
|
||||
path = os.path.join(self.path_entry.get(), 'README.md')
|
||||
if os.path.exists(path):
|
||||
subprocess.Popen(['xdg-open' if os.name != 'nt' else 'start', path])
|
||||
else:
|
||||
messagebox.showerror("Error", "README.md not found")
|
||||
|
||||
def open_agents(self):
|
||||
"""Open AGENTS.md"""
|
||||
import subprocess
|
||||
path = os.path.join(self.path_entry.get(), 'AGENTS.md')
|
||||
if os.path.exists(path):
|
||||
subprocess.Popen(['xdg-open' if os.name != 'nt' else 'start', path])
|
||||
else:
|
||||
messagebox.showerror("Error", "AGENTS.md not found")
|
||||
|
||||
def refresh_data(self):
|
||||
"""Refresh all data"""
|
||||
self.project_path = self.path_entry.get()
|
||||
self.agents = load_agents(self.project_path)
|
||||
self.skills = load_skills(self.project_path)
|
||||
self.commands = load_commands(self.project_path)
|
||||
self.rules = load_rules(self.project_path)
|
||||
|
||||
# Update tabs
|
||||
self.notebook.tab(0, text=f"Agents ({len(self.agents)})")
|
||||
self.notebook.tab(1, text=f"Skills ({len(self.skills)})")
|
||||
self.notebook.tab(2, text=f"Commands ({len(self.commands)})")
|
||||
self.notebook.tab(3, text=f"Rules ({len(self.rules)})")
|
||||
|
||||
# Repopulate
|
||||
self.populate_agents(self.agents)
|
||||
self.populate_skills(self.skills)
|
||||
|
||||
# Update status
|
||||
self.status_label.config(
|
||||
text=f"Ready | Agents: {len(self.agents)} | Skills: {len(self.skills)} | Commands: {len(self.commands)}"
|
||||
)
|
||||
|
||||
messagebox.showinfo("Success", "Data refreshed successfully!")
|
||||
|
||||
def apply_theme(self):
|
||||
theme = self.theme_var.get()
|
||||
font_family = self.font_var.get()
|
||||
font_size = int(self.size_var.get())
|
||||
font_tuple = (font_family, font_size)
|
||||
|
||||
if theme == 'dark':
|
||||
bg_color = '#2b2b2b'
|
||||
fg_color = '#ffffff'
|
||||
entry_bg = '#3c3c3c'
|
||||
frame_bg = '#2b2b2b'
|
||||
select_bg = '#0f5a9e'
|
||||
else:
|
||||
bg_color = '#f0f0f0'
|
||||
fg_color = '#000000'
|
||||
entry_bg = '#ffffff'
|
||||
frame_bg = '#f0f0f0'
|
||||
select_bg = '#e0e0e0'
|
||||
|
||||
self.configure(background=bg_color)
|
||||
|
||||
style = ttk.Style()
|
||||
style.configure('.', background=bg_color, foreground=fg_color, font=font_tuple)
|
||||
style.configure('TFrame', background=bg_color, font=font_tuple)
|
||||
style.configure('TLabel', background=bg_color, foreground=fg_color, font=font_tuple)
|
||||
style.configure('TNotebook', background=bg_color, font=font_tuple)
|
||||
style.configure('TNotebook.Tab', background=frame_bg, foreground=fg_color, font=font_tuple)
|
||||
style.map('TNotebook.Tab', background=[('selected', select_bg)])
|
||||
style.configure('Treeview', background=entry_bg, foreground=fg_color, fieldbackground=entry_bg, font=font_tuple)
|
||||
style.configure('Treeview.Heading', background=frame_bg, foreground=fg_color, font=font_tuple)
|
||||
style.configure('TEntry', fieldbackground=entry_bg, foreground=fg_color, font=font_tuple)
|
||||
style.configure('TButton', background=frame_bg, foreground=fg_color, font=font_tuple)
|
||||
|
||||
self.title_label.configure(font=(font_family, 18, 'bold'))
|
||||
self.version_label.configure(font=(font_family, 10))
|
||||
|
||||
def update_widget_colors(widget):
|
||||
try:
|
||||
widget.configure(background=bg_color)
|
||||
except:
|
||||
pass
|
||||
for child in widget.winfo_children():
|
||||
try:
|
||||
child.configure(background=bg_color)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
update_widget_colors(child)
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
update_widget_colors(self)
|
||||
except:
|
||||
pass
|
||||
|
||||
self.update()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MAIN
|
||||
# ============================================================================
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
app = ECCDashboard()
|
||||
app.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,19 +1,20 @@
|
||||
{
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "input=$(cat); user=$(whoami); cwd=$(echo \"$input\" | jq -r '.workspace.current_dir' | sed \"s|$HOME|~|g\"); model=$(echo \"$input\" | jq -r '.model.display_name'); time=$(date +%H:%M); remaining=$(echo \"$input\" | jq -r '.context_window.remaining_percentage // empty'); transcript=$(echo \"$input\" | jq -r '.transcript_path'); todo_count=$([ -f \"$transcript\" ] && grep -c '\"type\":\"todo\"' \"$transcript\" 2>/dev/null || echo 0); cd \"$(echo \"$input\" | jq -r '.workspace.current_dir')\" 2>/dev/null; branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo ''); status=''; [ -n \"$branch\" ] && { [ -n \"$(git status --porcelain 2>/dev/null)\" ] && status='*'; }; B='\\033[38;2;30;102;245m'; G='\\033[38;2;64;160;43m'; Y='\\033[38;2;223;142;29m'; M='\\033[38;2;136;57;239m'; C='\\033[38;2;23;146;153m'; R='\\033[0m'; T='\\033[38;2;76;79;105m'; printf \"${C}${user}${R}:${B}${cwd}${R}\"; [ -n \"$branch\" ] && printf \" ${G}${branch}${Y}${status}${R}\"; [ -n \"$remaining\" ] && printf \" ${M}ctx:${remaining}%%${R}\"; printf \" ${T}${model}${R} ${Y}${time}${R}\"; [ \"$todo_count\" -gt 0 ] && printf \" ${C}todos:${todo_count}${R}\"; echo",
|
||||
"description": "Custom status line showing: user:path branch* ctx:% model time todos:N"
|
||||
"command": "node \"<plugin-root>/scripts/hooks/ecc-statusline.js\"",
|
||||
"description": "ECC statusline: model | task | $cost tools files duration | dir | context bar"
|
||||
},
|
||||
"_comments": {
|
||||
"setup": "Replace <plugin-root> with your ECC installation path. For plugin installs, use the resolved path from CLAUDE_PLUGIN_ROOT.",
|
||||
"display": "Shows model name, current task, session cost, tool count, files modified, session duration, directory, and context usage bar with color thresholds.",
|
||||
"colors": {
|
||||
"B": "Blue - directory path",
|
||||
"G": "Green - git branch",
|
||||
"Y": "Yellow - dirty status, time",
|
||||
"M": "Magenta - context remaining",
|
||||
"C": "Cyan - username, todos",
|
||||
"T": "Gray - model name"
|
||||
"green": "Context used < 50%",
|
||||
"yellow": "Context used < 65%",
|
||||
"orange": "Context used < 80%",
|
||||
"red_blink": "Context used >= 80%"
|
||||
},
|
||||
"output_example": "affoon:~/projects/myapp main* ctx:73% sonnet-4.6 14:30 todos:3",
|
||||
"output_example": "Opus 4.6 | Fixing auth bug | $1.23 47t 5f 15m | myproject ███████░░░ 68%",
|
||||
"dependencies": "Reads bridge file from ecc-metrics-bridge.js PostToolUse hook. Both must be installed for full metrics display.",
|
||||
"usage": "Copy the statusLine object to your ~/.claude/settings.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,22 @@ User request → Claude picks a tool → PreToolUse hook runs → Tool executes
|
||||
|
||||
## Hooks in This Plugin
|
||||
|
||||
## Installing These Hooks Manually
|
||||
|
||||
For Claude Code manual installs, do not paste the raw repo `hooks.json` into `~/.claude/settings.json` or copy it directly into `~/.claude/hooks/hooks.json`. The checked-in file is plugin/repo-oriented and is meant to be installed through the ECC installer or loaded as a plugin.
|
||||
|
||||
Use the installer instead so hook commands are rewritten against your actual Claude root:
|
||||
|
||||
```bash
|
||||
bash ./install.sh --target claude --modules hooks-runtime
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh -File .\install.ps1 --target claude --modules hooks-runtime
|
||||
```
|
||||
|
||||
That installs resolved hooks to `~/.claude/hooks/hooks.json`. On Windows, the Claude config root is `%USERPROFILE%\\.claude`.
|
||||
|
||||
### PreToolUse Hooks
|
||||
|
||||
| Hook | Matcher | Behavior | Exit Code |
|
||||
@@ -26,6 +42,7 @@ User request → Claude picks a tool → PreToolUse hook runs → Tool executes
|
||||
| **Pre-commit quality check** | `Bash` | Runs quality checks before `git commit`: lints staged files, validates commit message format when provided via `-m/--message`, detects console.log/debugger/secrets | 2 (blocks critical) / 0 (warns) |
|
||||
| **Doc file warning** | `Write` | Warns about non-standard `.md`/`.txt` files (allows README, CLAUDE, CONTRIBUTING, CHANGELOG, LICENSE, SKILL, docs/, skills/); cross-platform path handling | 0 (warns) |
|
||||
| **Strategic compact** | `Edit\|Write` | Suggests manual `/compact` at logical intervals (every ~50 tool calls) | 0 (warns) |
|
||||
|
||||
### PostToolUse Hooks
|
||||
|
||||
| Hook | Matcher | What It Does |
|
||||
|
||||
+69
-98
@@ -7,62 +7,18 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "npx block-no-verify@1.1.2"
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/pre-bash-dispatcher.js"
|
||||
}
|
||||
],
|
||||
"description": "Block git hook-bypass flag to protect pre-commit, commit-msg, and pre-push hooks from being skipped",
|
||||
"id": "pre:bash:block-no-verify"
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/auto-tmux-dev.js\""
|
||||
}
|
||||
],
|
||||
"description": "Auto-start dev servers in tmux with directory-based session names",
|
||||
"id": "pre:bash:auto-tmux-dev"
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:bash:tmux-reminder\" \"scripts/hooks/pre-bash-tmux-reminder.js\" \"strict\""
|
||||
}
|
||||
],
|
||||
"description": "Reminder to use tmux for long-running commands",
|
||||
"id": "pre:bash:tmux-reminder"
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:bash:git-push-reminder\" \"scripts/hooks/pre-bash-git-push-reminder.js\" \"strict\""
|
||||
}
|
||||
],
|
||||
"description": "Reminder before git push to review changes",
|
||||
"id": "pre:bash:git-push-reminder"
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:bash:commit-quality\" \"scripts/hooks/pre-bash-commit-quality.js\" \"strict\""
|
||||
}
|
||||
],
|
||||
"description": "Pre-commit quality check: lint staged files, validate commit message format, detect console.log/debugger/secrets before committing",
|
||||
"id": "pre:bash:commit-quality"
|
||||
"description": "Consolidated Bash preflight dispatcher for quality, tmux, push, and GateGuard checks",
|
||||
"id": "pre:bash:dispatcher"
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:write:doc-file-warning\" \"scripts/hooks/doc-file-warning.js\" \"standard,strict\""
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:write:doc-file-warning scripts/hooks/doc-file-warning.js standard,strict"
|
||||
}
|
||||
],
|
||||
"description": "Doc file warning: warn about non-standard documentation files (exit code 0; warns only)",
|
||||
@@ -73,7 +29,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:edit-write:suggest-compact\" \"scripts/hooks/suggest-compact.js\" \"standard,strict\""
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:edit-write:suggest-compact scripts/hooks/suggest-compact.js standard,strict"
|
||||
}
|
||||
],
|
||||
"description": "Suggest manual compaction at logical intervals",
|
||||
@@ -84,7 +40,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags-shell.sh\" \"pre:observe\" \"skills/continuous-learning-v2/hooks/observe.sh\" \"standard,strict\"",
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" shell scripts/hooks/run-with-flags-shell.sh pre:observe skills/continuous-learning-v2/hooks/observe.sh standard,strict",
|
||||
"async": true,
|
||||
"timeout": 10
|
||||
}
|
||||
@@ -97,7 +53,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:governance-capture\" \"scripts/hooks/governance-capture.js\" \"standard,strict\"",
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:governance-capture scripts/hooks/governance-capture.js standard,strict",
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
@@ -109,7 +65,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:config-protection\" \"scripts/hooks/config-protection.js\" \"standard,strict\"",
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:config-protection scripts/hooks/config-protection.js standard,strict",
|
||||
"timeout": 5
|
||||
}
|
||||
],
|
||||
@@ -121,11 +77,23 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:mcp-health-check\" \"scripts/hooks/mcp-health-check.js\" \"standard,strict\""
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:mcp-health-check scripts/hooks/mcp-health-check.js standard,strict"
|
||||
}
|
||||
],
|
||||
"description": "Check MCP server health before MCP tool execution and block unhealthy MCP calls",
|
||||
"id": "pre:mcp-health-check"
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:edit-write:gateguard-fact-force scripts/hooks/gateguard-fact-force.js standard,strict",
|
||||
"timeout": 5
|
||||
}
|
||||
],
|
||||
"description": "Fact-forcing gate: block first Edit/Write/MultiEdit per file and demand investigation (importers, data schemas, user instruction) before allowing",
|
||||
"id": "pre:edit-write:gateguard-fact-force"
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
@@ -134,7 +102,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:compact\" \"scripts/hooks/pre-compact.js\" \"standard,strict\""
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:compact scripts/hooks/pre-compact.js standard,strict"
|
||||
}
|
||||
],
|
||||
"description": "Save state before context compaction",
|
||||
@@ -147,7 +115,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/session-start-bootstrap.js\""
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/session-start-bootstrap.js"
|
||||
}
|
||||
],
|
||||
"description": "Load previous context and detect package manager on new session",
|
||||
@@ -160,53 +128,20 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/post-bash-command-log.js\" audit"
|
||||
}
|
||||
],
|
||||
"description": "Audit log all bash commands to ~/.claude/bash-commands.log",
|
||||
"id": "post:bash:command-log-audit"
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/post-bash-command-log.js\" cost"
|
||||
}
|
||||
],
|
||||
"description": "Cost tracker - log bash tool usage with timestamps",
|
||||
"id": "post:bash:command-log-cost"
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:bash:pr-created\" \"scripts/hooks/post-bash-pr-created.js\" \"standard,strict\""
|
||||
}
|
||||
],
|
||||
"description": "Log PR URL and provide review command after PR creation",
|
||||
"id": "post:bash:pr-created"
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:bash:build-complete\" \"scripts/hooks/post-bash-build-complete.js\" \"standard,strict\"",
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/post-bash-dispatcher.js",
|
||||
"async": true,
|
||||
"timeout": 30
|
||||
}
|
||||
],
|
||||
"description": "Example: async hook for build analysis (runs in background without blocking)",
|
||||
"id": "post:bash:build-complete"
|
||||
"description": "Consolidated Bash postflight dispatcher for logging, PR, and build notifications",
|
||||
"id": "post:bash:dispatcher"
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:quality-gate\" \"scripts/hooks/quality-gate.js\" \"standard,strict\"",
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:quality-gate scripts/hooks/quality-gate.js standard,strict",
|
||||
"async": true,
|
||||
"timeout": 30
|
||||
}
|
||||
@@ -219,7 +154,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:edit:design-quality-check\" \"scripts/hooks/design-quality-check.js\" \"standard,strict\"",
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:edit:design-quality-check scripts/hooks/design-quality-check.js standard,strict",
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
@@ -231,7 +166,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:edit:accumulate\" \"scripts/hooks/post-edit-accumulator.js\" \"standard,strict\""
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:edit:accumulate scripts/hooks/post-edit-accumulator.js standard,strict"
|
||||
}
|
||||
],
|
||||
"description": "Record edited JS/TS file paths for batch format+typecheck at Stop time",
|
||||
@@ -242,7 +177,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:edit:console-warn\" \"scripts/hooks/post-edit-console-warn.js\" \"standard,strict\""
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:edit:console-warn scripts/hooks/post-edit-console-warn.js standard,strict"
|
||||
}
|
||||
],
|
||||
"description": "Warn about console.log statements after edits",
|
||||
@@ -253,7 +188,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:governance-capture\" \"scripts/hooks/governance-capture.js\" \"standard,strict\"",
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:governance-capture scripts/hooks/governance-capture.js standard,strict",
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
@@ -265,13 +200,49 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags-shell.sh\" \"post:observe\" \"skills/continuous-learning-v2/hooks/observe.sh\" \"standard,strict\"",
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node 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": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" shell scripts/hooks/run-with-flags-shell.sh post:observe skills/continuous-learning-v2/hooks/observe.sh standard,strict",
|
||||
"async": true,
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"description": "Capture tool use results for continuous learning",
|
||||
"id": "post:observe:continuous-learning"
|
||||
},
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:ecc-metrics-bridge scripts/hooks/ecc-metrics-bridge.js minimal,standard,strict",
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"description": "Maintain running session metrics aggregate for statusline and context monitor",
|
||||
"id": "post:ecc-metrics-bridge"
|
||||
},
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:ecc-context-monitor scripts/hooks/ecc-context-monitor.js standard,strict",
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"description": "Inject agent warnings on context exhaustion, high cost, scope creep, or tool loops",
|
||||
"id": "post:ecc-context-monitor"
|
||||
}
|
||||
],
|
||||
"PostToolUseFailure": [
|
||||
@@ -280,7 +251,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:mcp-health-check\" \"scripts/hooks/mcp-health-check.js\" \"standard,strict\""
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:mcp-health-check scripts/hooks/mcp-health-check.js standard,strict"
|
||||
}
|
||||
],
|
||||
"description": "Track failed MCP tool calls, mark unhealthy servers, and attempt reconnect",
|
||||
|
||||
@@ -193,6 +193,14 @@
|
||||
"framework-language"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lang:c",
|
||||
"family": "language",
|
||||
"description": "C engineering guidance using the shared C/C++ standards and testing stack. Currently resolves through the shared framework-language module.",
|
||||
"modules": [
|
||||
"framework-language"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lang:kotlin",
|
||||
"family": "language",
|
||||
@@ -350,7 +358,7 @@
|
||||
{
|
||||
"id": "skill:continuous-learning",
|
||||
"family": "skill",
|
||||
"description": "Session pattern extraction and continuous learning skill.",
|
||||
"description": "Legacy v1 Stop-hook session pattern extraction skill; prefer continuous-learning-v2 for new installs.",
|
||||
"modules": [
|
||||
"workflow-quality"
|
||||
]
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
{
|
||||
"id": "workflow-quality",
|
||||
"kind": "skills",
|
||||
"description": "Evaluation, TDD, verification, learning, and compaction skills.",
|
||||
"description": "Evaluation, TDD, verification, compaction, and learning skills, including the legacy continuous-learning v1 path.",
|
||||
"paths": [
|
||||
"skills/agent-sort",
|
||||
"skills/agent-introspection-debugging",
|
||||
|
||||
+178
-40
@@ -2,6 +2,9 @@
|
||||
"name": "ecc-universal",
|
||||
"version": "1.10.0",
|
||||
"description": "Complete collection of battle-tested Claude Code configs — agents, skills, hooks, rules, and legacy command shims evolved over 10+ months of intensive daily use by an Anthropic hackathon winner",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
"ai",
|
||||
@@ -39,64 +42,198 @@
|
||||
},
|
||||
"files": [
|
||||
".agents/",
|
||||
".claude-plugin/",
|
||||
".codex/",
|
||||
".codex-plugin/",
|
||||
".cursor/",
|
||||
".opencode/commands/",
|
||||
".opencode/dist/",
|
||||
".opencode/instructions/",
|
||||
".opencode/plugins/",
|
||||
".opencode/prompts/",
|
||||
".opencode/tools/",
|
||||
".opencode/index.ts",
|
||||
".opencode/opencode.json",
|
||||
".opencode/package.json",
|
||||
".opencode/tsconfig.json",
|
||||
".opencode/MIGRATION.md",
|
||||
".opencode/README.md",
|
||||
".gemini/",
|
||||
".opencode/",
|
||||
".mcp.json",
|
||||
"AGENTS.md",
|
||||
"VERSION",
|
||||
"agent.yaml",
|
||||
"agents/",
|
||||
"commands/",
|
||||
"contexts/",
|
||||
"examples/CLAUDE.md",
|
||||
"examples/user-CLAUDE.md",
|
||||
"examples/statusline.json",
|
||||
"hooks/",
|
||||
"install.ps1",
|
||||
"install.sh",
|
||||
"manifests/",
|
||||
"mcp-configs/",
|
||||
"plugins/",
|
||||
"rules/",
|
||||
"schemas/",
|
||||
"scripts/ci/",
|
||||
"scripts/ecc.js",
|
||||
"scripts/hooks/",
|
||||
"scripts/lib/",
|
||||
"scripts/catalog.js",
|
||||
"scripts/claw.js",
|
||||
"scripts/codex/merge-codex-config.js",
|
||||
"scripts/codex/merge-mcp-config.js",
|
||||
"scripts/doctor.js",
|
||||
"scripts/status.js",
|
||||
"scripts/sessions-cli.js",
|
||||
"scripts/ecc.js",
|
||||
"scripts/gemini-adapt-agents.js",
|
||||
"scripts/harness-audit.js",
|
||||
"scripts/hooks/",
|
||||
"scripts/install-apply.js",
|
||||
"scripts/install-plan.js",
|
||||
"scripts/lib/",
|
||||
"scripts/list-installed.js",
|
||||
"scripts/orchestration-status.js",
|
||||
"scripts/orchestrate-codex-worker.sh",
|
||||
"scripts/orchestrate-worktrees.js",
|
||||
"scripts/repair.js",
|
||||
"scripts/session-inspect.js",
|
||||
"scripts/sessions-cli.js",
|
||||
"scripts/setup-package-manager.js",
|
||||
"scripts/skill-create-output.js",
|
||||
"scripts/codex/merge-codex-config.js",
|
||||
"scripts/codex/merge-mcp-config.js",
|
||||
"scripts/repair.js",
|
||||
"scripts/harness-audit.js",
|
||||
"scripts/session-inspect.js",
|
||||
"scripts/status.js",
|
||||
"scripts/uninstall.js",
|
||||
"skills/",
|
||||
"AGENTS.md",
|
||||
".claude-plugin/plugin.json",
|
||||
".claude-plugin/README.md",
|
||||
".codex-plugin/plugin.json",
|
||||
".codex-plugin/README.md",
|
||||
".mcp.json",
|
||||
"install.sh",
|
||||
"install.ps1",
|
||||
"llms.txt"
|
||||
"skills/agent-harness-construction/",
|
||||
"skills/agent-introspection-debugging/",
|
||||
"skills/agent-sort/",
|
||||
"skills/agentic-engineering/",
|
||||
"skills/ai-first-engineering/",
|
||||
"skills/ai-regression-testing/",
|
||||
"skills/android-clean-architecture/",
|
||||
"skills/api-connector-builder/",
|
||||
"skills/api-design/",
|
||||
"skills/article-writing/",
|
||||
"skills/automation-audit-ops/",
|
||||
"skills/autonomous-loops/",
|
||||
"skills/backend-patterns/",
|
||||
"skills/blueprint/",
|
||||
"skills/brand-voice/",
|
||||
"skills/carrier-relationship-management/",
|
||||
"skills/claude-api/",
|
||||
"skills/claude-devfleet/",
|
||||
"skills/clickhouse-io/",
|
||||
"skills/code-tour/",
|
||||
"skills/coding-standards/",
|
||||
"skills/compose-multiplatform-patterns/",
|
||||
"skills/configure-ecc/",
|
||||
"skills/connections-optimizer/",
|
||||
"skills/content-engine/",
|
||||
"skills/content-hash-cache-pattern/",
|
||||
"skills/continuous-agent-loop/",
|
||||
"skills/continuous-learning/",
|
||||
"skills/continuous-learning-v2/",
|
||||
"skills/cost-aware-llm-pipeline/",
|
||||
"skills/council/",
|
||||
"skills/cpp-coding-standards/",
|
||||
"skills/cpp-testing/",
|
||||
"skills/crosspost/",
|
||||
"skills/csharp-testing/",
|
||||
"skills/customer-billing-ops/",
|
||||
"skills/customs-trade-compliance/",
|
||||
"skills/dart-flutter-patterns/",
|
||||
"skills/dashboard-builder/",
|
||||
"skills/data-scraper-agent/",
|
||||
"skills/database-migrations/",
|
||||
"skills/deep-research/",
|
||||
"skills/defi-amm-security/",
|
||||
"skills/deployment-patterns/",
|
||||
"skills/django-patterns/",
|
||||
"skills/django-security/",
|
||||
"skills/django-tdd/",
|
||||
"skills/django-verification/",
|
||||
"skills/dmux-workflows/",
|
||||
"skills/docker-patterns/",
|
||||
"skills/dotnet-patterns/",
|
||||
"skills/e2e-testing/",
|
||||
"skills/ecc-tools-cost-audit/",
|
||||
"skills/email-ops/",
|
||||
"skills/energy-procurement/",
|
||||
"skills/enterprise-agent-ops/",
|
||||
"skills/eval-harness/",
|
||||
"skills/evm-token-decimals/",
|
||||
"skills/exa-search/",
|
||||
"skills/fal-ai-media/",
|
||||
"skills/finance-billing-ops/",
|
||||
"skills/foundation-models-on-device/",
|
||||
"skills/frontend-design/",
|
||||
"skills/frontend-patterns/",
|
||||
"skills/frontend-slides/",
|
||||
"skills/github-ops/",
|
||||
"skills/golang-patterns/",
|
||||
"skills/golang-testing/",
|
||||
"skills/google-workspace-ops/",
|
||||
"skills/healthcare-phi-compliance/",
|
||||
"skills/hipaa-compliance/",
|
||||
"skills/hookify-rules/",
|
||||
"skills/inventory-demand-planning/",
|
||||
"skills/investor-materials/",
|
||||
"skills/investor-outreach/",
|
||||
"skills/iterative-retrieval/",
|
||||
"skills/java-coding-standards/",
|
||||
"skills/jira-integration/",
|
||||
"skills/jpa-patterns/",
|
||||
"skills/knowledge-ops/",
|
||||
"skills/kotlin-coroutines-flows/",
|
||||
"skills/kotlin-exposed-patterns/",
|
||||
"skills/kotlin-ktor-patterns/",
|
||||
"skills/kotlin-patterns/",
|
||||
"skills/kotlin-testing/",
|
||||
"skills/laravel-patterns/",
|
||||
"skills/laravel-plugin-discovery/",
|
||||
"skills/laravel-security/",
|
||||
"skills/laravel-tdd/",
|
||||
"skills/laravel-verification/",
|
||||
"skills/lead-intelligence/",
|
||||
"skills/liquid-glass-design/",
|
||||
"skills/llm-trading-agent-security/",
|
||||
"skills/logistics-exception-management/",
|
||||
"skills/manim-video/",
|
||||
"skills/market-research/",
|
||||
"skills/mcp-server-patterns/",
|
||||
"skills/messages-ops/",
|
||||
"skills/nanoclaw-repl/",
|
||||
"skills/nestjs-patterns/",
|
||||
"skills/nodejs-keccak256/",
|
||||
"skills/nutrient-document-processing/",
|
||||
"skills/perl-patterns/",
|
||||
"skills/perl-security/",
|
||||
"skills/perl-testing/",
|
||||
"skills/plankton-code-quality/",
|
||||
"skills/postgres-patterns/",
|
||||
"skills/product-capability/",
|
||||
"skills/production-scheduling/",
|
||||
"skills/project-flow-ops/",
|
||||
"skills/prompt-optimizer/",
|
||||
"skills/python-patterns/",
|
||||
"skills/python-testing/",
|
||||
"skills/quality-nonconformance/",
|
||||
"skills/ralphinho-rfc-pipeline/",
|
||||
"skills/regex-vs-llm-structured-text/",
|
||||
"skills/remotion-video-creation/",
|
||||
"skills/research-ops/",
|
||||
"skills/returns-reverse-logistics/",
|
||||
"skills/rust-patterns/",
|
||||
"skills/rust-testing/",
|
||||
"skills/search-first/",
|
||||
"skills/security-bounty-hunter/",
|
||||
"skills/security-review/",
|
||||
"skills/security-scan/",
|
||||
"skills/seo/",
|
||||
"skills/skill-stocktake/",
|
||||
"skills/social-graph-ranker/",
|
||||
"skills/springboot-patterns/",
|
||||
"skills/springboot-security/",
|
||||
"skills/springboot-tdd/",
|
||||
"skills/springboot-verification/",
|
||||
"skills/strategic-compact/",
|
||||
"skills/swift-actor-persistence/",
|
||||
"skills/swift-concurrency-6-2/",
|
||||
"skills/swift-protocol-di-testing/",
|
||||
"skills/swiftui-patterns/",
|
||||
"skills/tdd-workflow/",
|
||||
"skills/team-builder/",
|
||||
"skills/terminal-ops/",
|
||||
"skills/token-budget-advisor/",
|
||||
"skills/ui-demo/",
|
||||
"skills/unified-notifications-ops/",
|
||||
"skills/verification-loop/",
|
||||
"skills/video-editing/",
|
||||
"skills/videodb/",
|
||||
"skills/visa-doc-translate/",
|
||||
"skills/workspace-surface-audit/",
|
||||
"skills/x-api/",
|
||||
"the-security-guide.md"
|
||||
],
|
||||
"bin": {
|
||||
"ecc": "scripts/ecc.js",
|
||||
@@ -115,7 +252,8 @@
|
||||
"test": "node scripts/ci/check-unicode-safety.js && node scripts/ci/validate-agents.js && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js && node scripts/ci/validate-skills.js && node scripts/ci/validate-hooks.js && node scripts/ci/validate-install-manifests.js && node scripts/ci/validate-no-personal-paths.js && npm run catalog:check && node tests/run-all.js",
|
||||
"coverage": "c8 --all --include=\"scripts/**/*.js\" --check-coverage --lines 80 --functions 80 --branches 80 --statements 80 --reporter=text --reporter=lcov node tests/run-all.js",
|
||||
"build:opencode": "node scripts/build-opencode.js",
|
||||
"prepack": "npm run build:opencode"
|
||||
"prepack": "npm run build:opencode",
|
||||
"dashboard": "python3 ./ecc_dashboard.py"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -136,4 +274,4 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"packageManager": "yarn@4.9.2+sha512.1fc009bc09d13cfd0e19efa44cbfc2b9cf6ca61482725eb35bbc5e257e093ebf4130db6dfe15d604ff4b79efd8e1e8e99b25fa7d0a6197c9f9826358d4d65c3c"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
[project]
|
||||
name = "llm-abstraction"
|
||||
version = "0.1.0"
|
||||
description = "Provider-agnostic LLM abstraction layer"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "Affaan Mustafa", email = "affaan@example.com"}
|
||||
]
|
||||
keywords = ["llm", "openai", "anthropic", "ollama", "ai"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"anthropic>=0.25.0",
|
||||
"openai>=1.30.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"pytest-cov>=4.1",
|
||||
"pytest-mock>=3.12",
|
||||
"ruff>=0.4",
|
||||
"mypy>=1.10",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/affaan-m/everything-claude-code"
|
||||
Repository = "https://github.com/affaan-m/everything-claude-code"
|
||||
|
||||
[project.scripts]
|
||||
llm-select = "llm.cli.selector:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/llm"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
filterwarnings = ["ignore::DeprecationWarning"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["src/llm"]
|
||||
branch = true
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
"raise NotImplementedError",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
src-path = ["src"]
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W", "UP"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
src_paths = ["src"]
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
@@ -12,6 +12,53 @@ const COMMANDS_DIR = path.join(ROOT_DIR, 'commands');
|
||||
const AGENTS_DIR = path.join(ROOT_DIR, 'agents');
|
||||
const SKILLS_DIR = path.join(ROOT_DIR, 'skills');
|
||||
|
||||
function validateFrontmatter(file, content) {
|
||||
if (!content.startsWith('---\n')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const endIndex = content.indexOf('\n---\n', 4);
|
||||
if (endIndex === -1) {
|
||||
return [`${file} - frontmatter block is missing a closing --- delimiter`];
|
||||
}
|
||||
|
||||
const block = content.slice(4, endIndex);
|
||||
const errors = [];
|
||||
|
||||
for (const rawLine of block.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
||||
if (!match) {
|
||||
errors.push(`${file} - invalid frontmatter line: ${rawLine}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = match[2].trim();
|
||||
const isQuoted = (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
);
|
||||
|
||||
if (!isQuoted && value.startsWith('[') && !value.endsWith(']')) {
|
||||
errors.push(
|
||||
`${file} - frontmatter value for "${match[1]}" starts with "[" but is not a closed YAML sequence; wrap it in quotes`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isQuoted && value.startsWith('{') && !value.endsWith('}')) {
|
||||
errors.push(
|
||||
`${file} - frontmatter value for "${match[1]}" starts with "{" but is not a closed YAML mapping; wrap it in quotes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateCommands() {
|
||||
if (!fs.existsSync(COMMANDS_DIR)) {
|
||||
console.log('No commands directory found, skipping validation');
|
||||
@@ -68,6 +115,11 @@ function validateCommands() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const error of validateFrontmatter(file, content)) {
|
||||
console.error(`ERROR: ${error}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// Strip fenced code blocks before checking cross-references.
|
||||
// Examples/templates inside ``` blocks are not real references.
|
||||
const contentNoCodeBlocks = content.replace(/```[\s\S]*?```/g, '');
|
||||
|
||||
@@ -73,7 +73,7 @@ function validateHookEntry(hook, label) {
|
||||
console.error(`ERROR: ${label} missing or invalid 'command' field`);
|
||||
hasErrors = true;
|
||||
} else if (typeof hook.command === 'string') {
|
||||
const nodeEMatch = hook.command.match(/^node -e "(.*)"$/s);
|
||||
const nodeEMatch = hook.command.match(/^node -e "((?:[^"\\]|\\.)*)"(?:\s|$)/s);
|
||||
if (nodeEMatch) {
|
||||
try {
|
||||
new vm.Script(nodeEMatch[1].replace(/\\\\/g, '\\').replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t'));
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Reject unsafe GitHub Actions patterns that execute or checkout untrusted PR code
|
||||
* from privileged events such as workflow_run or pull_request_target.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DEFAULT_WORKFLOWS_DIR = path.join(__dirname, '../../.github/workflows');
|
||||
|
||||
const RULES = [
|
||||
{
|
||||
event: 'workflow_run',
|
||||
eventPattern: /\bworkflow_run\s*:/m,
|
||||
description: 'workflow_run must not checkout an untrusted workflow_run head ref/repository',
|
||||
expressionPattern: /\$\{\{\s*github\.event\.workflow_run\.(?:head_branch|head_sha|head_repository(?:\.[A-Za-z0-9_.]+)?)\s*\}\}|\$\{\{\s*github\.event\.workflow_run\.pull_requests\[\d+\]\.head\.(?:ref|sha|repo\.full_name)\s*\}\}/g,
|
||||
},
|
||||
{
|
||||
event: 'pull_request_target',
|
||||
eventPattern: /\bpull_request_target\s*:/m,
|
||||
description: 'pull_request_target must not checkout an untrusted pull_request head ref/repository',
|
||||
expressionPattern: /\$\{\{\s*github\.event\.pull_request\.head\.(?:ref|sha|repo\.full_name)\s*\}\}/g,
|
||||
},
|
||||
];
|
||||
|
||||
function getWorkflowFiles(workflowsDir) {
|
||||
if (!fs.existsSync(workflowsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(workflowsDir)
|
||||
.filter(file => /\.(?:yml|yaml)$/i.test(file))
|
||||
.map(file => path.join(workflowsDir, file))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function getLineNumber(source, index) {
|
||||
return source.slice(0, index).split(/\r?\n/).length;
|
||||
}
|
||||
|
||||
function extractCheckoutSteps(source) {
|
||||
const blocks = [];
|
||||
const lines = source.split(/\r?\n/);
|
||||
let current = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const stepStart = line.match(/^(\s*)-\s+/);
|
||||
|
||||
if (stepStart) {
|
||||
if (current) {
|
||||
blocks.push(current);
|
||||
}
|
||||
|
||||
current = {
|
||||
indent: stepStart[1].length,
|
||||
startLine: i + 1,
|
||||
lines: [line],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
current.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
blocks.push(current);
|
||||
}
|
||||
|
||||
return blocks
|
||||
.map(block => ({
|
||||
startLine: block.startLine,
|
||||
text: block.lines.join('\n'),
|
||||
}))
|
||||
.filter(block => /uses:\s*actions\/checkout@/m.test(block.text));
|
||||
}
|
||||
|
||||
function findViolations(filePath, source) {
|
||||
const violations = [];
|
||||
const checkoutSteps = extractCheckoutSteps(source);
|
||||
|
||||
for (const rule of RULES) {
|
||||
if (!rule.eventPattern.test(source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const step of checkoutSteps) {
|
||||
for (const match of step.text.matchAll(rule.expressionPattern)) {
|
||||
violations.push({
|
||||
filePath,
|
||||
event: rule.event,
|
||||
description: rule.description,
|
||||
expression: match[0],
|
||||
line: step.startLine + getLineNumber(step.text, match.index) - 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
function validateWorkflowSecurity(workflowsDir = DEFAULT_WORKFLOWS_DIR) {
|
||||
const files = getWorkflowFiles(workflowsDir);
|
||||
const violations = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
const source = fs.readFileSync(filePath, 'utf8');
|
||||
violations.push(...findViolations(filePath, source));
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
for (const violation of violations) {
|
||||
console.error(
|
||||
`ERROR: ${path.basename(violation.filePath)}:${violation.line} - ${violation.description}`,
|
||||
);
|
||||
console.error(` Unsafe expression: ${violation.expression}`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.log(`Validated workflow security for ${files.length} workflow files`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
process.exit(validateWorkflowSecurity(process.env.ECC_WORKFLOWS_DIR || DEFAULT_WORKFLOWS_DIR));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_WORKFLOWS_DIR,
|
||||
extractCheckoutSteps,
|
||||
findViolations,
|
||||
validateWorkflowSecurity,
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const TOOL_NAME_MAP = new Map([
|
||||
['Read', 'read_file'],
|
||||
['Write', 'write_file'],
|
||||
['Edit', 'replace'],
|
||||
['Bash', 'run_shell_command'],
|
||||
['Grep', 'grep_search'],
|
||||
['Glob', 'glob'],
|
||||
['WebSearch', 'google_web_search'],
|
||||
['WebFetch', 'web_fetch'],
|
||||
]);
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
'Adapt ECC agent frontmatter for Gemini CLI.',
|
||||
'',
|
||||
'Usage:',
|
||||
' node scripts/gemini-adapt-agents.js [agents-dir]',
|
||||
'',
|
||||
'Defaults to .gemini/agents under the current working directory.',
|
||||
'Rewrites tools: to Gemini-compatible tool names and removes unsupported color: metadata.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
if (argv.includes('--help') || argv.includes('-h')) {
|
||||
return { help: true };
|
||||
}
|
||||
|
||||
const positional = argv.filter(arg => !arg.startsWith('-'));
|
||||
if (positional.length > 1) {
|
||||
throw new Error('Expected at most one agents directory argument');
|
||||
}
|
||||
|
||||
return {
|
||||
help: false,
|
||||
agentsDir: path.resolve(positional[0] || path.join(process.cwd(), '.gemini', 'agents')),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureDirectory(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
throw new Error(`Agents directory not found: ${dirPath}`);
|
||||
}
|
||||
|
||||
if (!fs.statSync(dirPath).isDirectory()) {
|
||||
throw new Error(`Expected a directory: ${dirPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function stripQuotes(value) {
|
||||
return value.trim().replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function parseToolList(line) {
|
||||
const match = line.match(/^(\s*tools\s*:\s*)\[(.*)\]\s*$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawItems = match[2].trim();
|
||||
if (!rawItems) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawItems
|
||||
.split(',')
|
||||
.map(part => stripQuotes(part))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function adaptToolName(toolName) {
|
||||
const mapped = TOOL_NAME_MAP.get(toolName);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
if (toolName.startsWith('mcp__')) {
|
||||
return toolName
|
||||
.replace(/^mcp__/, 'mcp_')
|
||||
.replace(/__/g, '_')
|
||||
.replace(/[^A-Za-z0-9_]/g, '_')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
return toolName;
|
||||
}
|
||||
|
||||
function formatToolLine(tools) {
|
||||
return `tools: [${tools.map(tool => JSON.stringify(tool)).join(', ')}]`;
|
||||
}
|
||||
|
||||
function adaptFrontmatter(text) {
|
||||
const match = text.match(/^---\n([\s\S]*?)\n---(\n|$)/);
|
||||
if (!match) {
|
||||
return { text, changed: false };
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const updatedLines = [];
|
||||
|
||||
for (const line of match[1].split('\n')) {
|
||||
if (/^\s*color\s*:/.test(line)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tools = parseToolList(line);
|
||||
if (tools) {
|
||||
const adaptedTools = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const tool of tools.map(adaptToolName)) {
|
||||
if (seen.has(tool)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(tool);
|
||||
adaptedTools.push(tool);
|
||||
}
|
||||
|
||||
const updatedLine = formatToolLine(adaptedTools);
|
||||
if (updatedLine !== line) {
|
||||
changed = true;
|
||||
}
|
||||
updatedLines.push(updatedLine);
|
||||
continue;
|
||||
}
|
||||
|
||||
updatedLines.push(line);
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { text, changed: false };
|
||||
}
|
||||
|
||||
return {
|
||||
text: `---\n${updatedLines.join('\n')}\n---${match[2]}${text.slice(match[0].length)}`,
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
function adaptAgents(dirPath) {
|
||||
ensureDirectory(dirPath);
|
||||
|
||||
let updated = 0;
|
||||
let unchanged = 0;
|
||||
|
||||
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(dirPath, entry.name);
|
||||
const original = fs.readFileSync(filePath, 'utf8');
|
||||
const adapted = adaptFrontmatter(original);
|
||||
|
||||
if (adapted.changed) {
|
||||
fs.writeFileSync(filePath, adapted.text);
|
||||
updated += 1;
|
||||
} else {
|
||||
unchanged += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { updated, unchanged };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const result = adaptAgents(options.agentsDir);
|
||||
console.log(`Updated ${result.updated} agent file(s); ${result.unchanged} already compatible`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const CATEGORIES = [
|
||||
@@ -187,7 +188,7 @@ function detectTargetMode(rootDir) {
|
||||
}
|
||||
|
||||
function findPluginInstall(rootDir) {
|
||||
const homeDir = process.env.HOME || '';
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir() || '';
|
||||
const pluginDirs = [
|
||||
'ecc',
|
||||
'ecc@ecc',
|
||||
@@ -196,7 +197,9 @@ function findPluginInstall(rootDir) {
|
||||
];
|
||||
const candidateRoots = [
|
||||
path.join(rootDir, '.claude', 'plugins'),
|
||||
path.join(rootDir, '.claude', 'plugins', 'marketplaces'),
|
||||
homeDir && path.join(homeDir, '.claude', 'plugins'),
|
||||
homeDir && path.join(homeDir, '.claude', 'plugins', 'marketplaces'),
|
||||
].filter(Boolean);
|
||||
const candidates = candidateRoots.flatMap((pluginsDir) =>
|
||||
pluginDirs.flatMap((pluginDir) => [
|
||||
|
||||
@@ -30,19 +30,10 @@ const { spawnSync } = require('child_process');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024; // 1MB limit
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
|
||||
process.stdin.on('data', chunk => {
|
||||
if (data.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - data.length;
|
||||
data += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
let input;
|
||||
function run(rawInput) {
|
||||
try {
|
||||
input = JSON.parse(data);
|
||||
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
|
||||
const cmd = input.tool_input?.command || '';
|
||||
|
||||
// Detect dev server commands: npm run dev, pnpm dev, yarn dev, bun run dev
|
||||
@@ -60,7 +51,13 @@ process.stdin.on('end', () => {
|
||||
// Windows: open in a new cmd window (non-blocking)
|
||||
// Escape double quotes in cmd for cmd /k syntax
|
||||
const escapedCmd = cmd.replace(/"/g, '""');
|
||||
input.tool_input.command = `start "DevServer-${sessionName}" cmd /k "${escapedCmd}"`;
|
||||
return JSON.stringify({
|
||||
...input,
|
||||
tool_input: {
|
||||
...input.tool_input,
|
||||
command: `start "DevServer-${sessionName}" cmd /k "${escapedCmd}"`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Unix (macOS/Linux): Check tmux is available before transforming
|
||||
const tmuxCheck = spawnSync('which', ['tmux'], { encoding: 'utf8' });
|
||||
@@ -73,16 +70,38 @@ process.stdin.on('end', () => {
|
||||
// 2. Create new detached session with the dev command
|
||||
// 3. Echo confirmation message with instructions for viewing logs
|
||||
const transformedCmd = `SESSION="${sessionName}"; tmux kill-session -t "$SESSION" 2>/dev/null || true; tmux new-session -d -s "$SESSION" '${escapedCmd}' && echo "[Hook] Dev server started in tmux session '${sessionName}'. View logs: tmux capture-pane -t ${sessionName} -p -S -100"`;
|
||||
|
||||
input.tool_input.command = transformedCmd;
|
||||
return JSON.stringify({
|
||||
...input,
|
||||
tool_input: {
|
||||
...input.tool_input,
|
||||
command: transformedCmd,
|
||||
},
|
||||
});
|
||||
}
|
||||
// else: tmux not found, pass through original command unchanged
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify(input));
|
||||
|
||||
return JSON.stringify(input);
|
||||
} catch {
|
||||
// Invalid input — pass through original data unchanged
|
||||
process.stdout.write(data);
|
||||
return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (data.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - data.length;
|
||||
data += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
process.stdout.write(run(data));
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const { isHookEnabled } = require('../lib/hook-flags');
|
||||
|
||||
const { run: runBlockNoVerify } = require('./block-no-verify');
|
||||
const { run: runAutoTmuxDev } = require('./auto-tmux-dev');
|
||||
const { run: runTmuxReminder } = require('./pre-bash-tmux-reminder');
|
||||
const { run: runGitPushReminder } = require('./pre-bash-git-push-reminder');
|
||||
const { run: runCommitQuality } = require('./pre-bash-commit-quality');
|
||||
const { run: runGateGuard } = require('./gateguard-fact-force');
|
||||
const { run: runCommandLog } = require('./post-bash-command-log');
|
||||
const { run: runPrCreated } = require('./post-bash-pr-created');
|
||||
const { run: runBuildComplete } = require('./post-bash-build-complete');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
|
||||
const PRE_BASH_HOOKS = [
|
||||
{
|
||||
id: 'pre:bash:block-no-verify',
|
||||
profiles: 'minimal,standard,strict',
|
||||
run: rawInput => runBlockNoVerify(rawInput),
|
||||
},
|
||||
{
|
||||
id: 'pre:bash:auto-tmux-dev',
|
||||
run: rawInput => runAutoTmuxDev(rawInput),
|
||||
},
|
||||
{
|
||||
id: 'pre:bash:tmux-reminder',
|
||||
profiles: 'strict',
|
||||
run: rawInput => runTmuxReminder(rawInput),
|
||||
},
|
||||
{
|
||||
id: 'pre:bash:git-push-reminder',
|
||||
profiles: 'strict',
|
||||
run: rawInput => runGitPushReminder(rawInput),
|
||||
},
|
||||
{
|
||||
id: 'pre:bash:commit-quality',
|
||||
profiles: 'strict',
|
||||
run: rawInput => runCommitQuality(rawInput),
|
||||
},
|
||||
{
|
||||
id: 'pre:bash:gateguard-fact-force',
|
||||
profiles: 'standard,strict',
|
||||
run: rawInput => runGateGuard(rawInput),
|
||||
},
|
||||
];
|
||||
|
||||
const POST_BASH_HOOKS = [
|
||||
{
|
||||
id: 'post:bash:command-log-audit',
|
||||
run: rawInput => runCommandLog(rawInput, 'audit'),
|
||||
},
|
||||
{
|
||||
id: 'post:bash:command-log-cost',
|
||||
run: rawInput => runCommandLog(rawInput, 'cost'),
|
||||
},
|
||||
{
|
||||
id: 'post:bash:pr-created',
|
||||
profiles: 'standard,strict',
|
||||
run: rawInput => runPrCreated(rawInput),
|
||||
},
|
||||
{
|
||||
id: 'post:bash:build-complete',
|
||||
profiles: 'standard,strict',
|
||||
run: rawInput => runBuildComplete(rawInput),
|
||||
},
|
||||
];
|
||||
|
||||
function readStdinRaw() {
|
||||
return new Promise(resolve => {
|
||||
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', () => resolve(raw));
|
||||
process.stdin.on('error', () => resolve(raw));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeHookResult(previousRaw, output) {
|
||||
if (typeof output === 'string' || Buffer.isBuffer(output)) {
|
||||
return {
|
||||
raw: String(output),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (output && typeof output === 'object') {
|
||||
const nextRaw = Object.prototype.hasOwnProperty.call(output, 'stdout')
|
||||
? String(output.stdout ?? '')
|
||||
: !Number.isInteger(output.exitCode) || output.exitCode === 0
|
||||
? previousRaw
|
||||
: '';
|
||||
|
||||
return {
|
||||
raw: nextRaw,
|
||||
stderr: typeof output.stderr === 'string' ? output.stderr : '',
|
||||
exitCode: Number.isInteger(output.exitCode) ? output.exitCode : 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
raw: previousRaw,
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function runHooks(rawInput, hooks) {
|
||||
let currentRaw = rawInput;
|
||||
let stderr = '';
|
||||
|
||||
for (const hook of hooks) {
|
||||
if (!isHookEnabled(hook.id, { profiles: hook.profiles })) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = normalizeHookResult(currentRaw, hook.run(currentRaw));
|
||||
currentRaw = result.raw;
|
||||
if (result.stderr) {
|
||||
stderr += result.stderr.endsWith('\n') ? result.stderr : `${result.stderr}\n`;
|
||||
}
|
||||
if (result.exitCode !== 0) {
|
||||
return { output: currentRaw, stderr, exitCode: result.exitCode };
|
||||
}
|
||||
} catch (error) {
|
||||
stderr += `[Hook] ${hook.id} failed: ${error.message}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return { output: currentRaw, stderr, exitCode: 0 };
|
||||
}
|
||||
|
||||
function runPreBash(rawInput) {
|
||||
return runHooks(rawInput, PRE_BASH_HOOKS);
|
||||
}
|
||||
|
||||
function runPostBash(rawInput) {
|
||||
return runHooks(rawInput, POST_BASH_HOOKS);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const mode = process.argv[2];
|
||||
const raw = await readStdinRaw();
|
||||
|
||||
const result = mode === 'post'
|
||||
? runPostBash(raw)
|
||||
: runPreBash(raw);
|
||||
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
process.stdout.write(result.output);
|
||||
process.exit(result.exitCode);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(error => {
|
||||
process.stderr.write(`[Hook] bash-hook-dispatcher failed: ${error.message}\n`);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PRE_BASH_HOOKS,
|
||||
POST_BASH_HOOKS,
|
||||
runPreBash,
|
||||
runPostBash,
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PreToolUse Hook: Block --no-verify flag
|
||||
*
|
||||
* Blocks git hook-bypass flags (--no-verify, -c core.hooksPath=) to protect
|
||||
* pre-commit, commit-msg, and pre-push hooks from being skipped by AI agents.
|
||||
*
|
||||
* Replaces the previous npx-based invocation that failed in pnpm-only projects
|
||||
* (EBADDEVENGINES) and could not be disabled via ECC_DISABLED_HOOKS.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 = allow (not a git command or no bypass flags)
|
||||
* 2 = block (bypass flag detected)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let raw = '';
|
||||
|
||||
/**
|
||||
* Git commands that support the --no-verify flag.
|
||||
*/
|
||||
const GIT_COMMANDS_WITH_NO_VERIFY = [
|
||||
'commit',
|
||||
'push',
|
||||
'merge',
|
||||
'cherry-pick',
|
||||
'rebase',
|
||||
'am',
|
||||
];
|
||||
|
||||
/**
|
||||
* Characters that can appear immediately before 'git' in a command string.
|
||||
*/
|
||||
const VALID_BEFORE_GIT = ' \t\n\r;&|$`(<{!"\']/.~\\';
|
||||
|
||||
/**
|
||||
* Check if a position in the input is inside a shell comment.
|
||||
*/
|
||||
function isInComment(input, idx) {
|
||||
const lineStart = input.lastIndexOf('\n', idx - 1) + 1;
|
||||
const before = input.slice(lineStart, idx);
|
||||
for (let i = 0; i < before.length; i++) {
|
||||
if (before.charAt(i) === '#') {
|
||||
const prev = i > 0 ? before.charAt(i - 1) : '';
|
||||
if (prev !== '$' && prev !== '\\') return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next 'git' token in the input starting from a position.
|
||||
*/
|
||||
function findGit(input, start) {
|
||||
let pos = start;
|
||||
while (pos < input.length) {
|
||||
const idx = input.indexOf('git', pos);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const isExe = input.slice(idx + 3, idx + 7).toLowerCase() === '.exe';
|
||||
const len = isExe ? 7 : 3;
|
||||
const after = input[idx + len] || ' ';
|
||||
if (!/[\s"']/.test(after)) {
|
||||
pos = idx + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const before = idx > 0 ? input[idx - 1] : ' ';
|
||||
if (VALID_BEFORE_GIT.includes(before)) return { idx, len };
|
||||
pos = idx + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which git subcommand (commit, push, etc.) is being invoked.
|
||||
* Returns { command, offset } where offset is the position right after the
|
||||
* subcommand keyword, so callers can scope flag checks to only that portion.
|
||||
*/
|
||||
function detectGitCommand(input) {
|
||||
let start = 0;
|
||||
while (start < input.length) {
|
||||
const git = findGit(input, start);
|
||||
if (!git) return null;
|
||||
|
||||
if (isInComment(input, git.idx)) {
|
||||
start = git.idx + git.len;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the first matching subcommand token after "git".
|
||||
// We pick the one closest to "git" so that argument values like
|
||||
// "git push origin commit" don't misclassify "commit" as the subcommand.
|
||||
let bestCmd = null;
|
||||
let bestIdx = Infinity;
|
||||
|
||||
for (const cmd of GIT_COMMANDS_WITH_NO_VERIFY) {
|
||||
let searchPos = git.idx + git.len;
|
||||
while (searchPos < input.length) {
|
||||
const cmdIdx = input.indexOf(cmd, searchPos);
|
||||
if (cmdIdx === -1) break;
|
||||
|
||||
const before = cmdIdx > 0 ? input[cmdIdx - 1] : ' ';
|
||||
const after = input[cmdIdx + cmd.length] || ' ';
|
||||
if (!/\s/.test(before)) { searchPos = cmdIdx + 1; continue; }
|
||||
if (!/[\s;&#|>)\]}"']/.test(after) && after !== '') { searchPos = cmdIdx + 1; continue; }
|
||||
if (/[;|]/.test(input.slice(git.idx + git.len, cmdIdx))) break;
|
||||
if (isInComment(input, cmdIdx)) { searchPos = cmdIdx + 1; continue; }
|
||||
|
||||
// Verify this token is the first non-flag word after "git" — i.e. the
|
||||
// actual subcommand, not an argument value to a different subcommand.
|
||||
const gap = input.slice(git.idx + git.len, cmdIdx);
|
||||
const tokens = gap.trim().split(/\s+/).filter(Boolean);
|
||||
// Every token before the candidate must be a flag or a flag argument.
|
||||
// Git global flags like -c take a value argument (e.g. -c key=value).
|
||||
let onlyFlagsAndArgs = true;
|
||||
let expectFlagArg = false;
|
||||
for (const t of tokens) {
|
||||
if (expectFlagArg) { expectFlagArg = false; continue; }
|
||||
if (t.startsWith('-')) {
|
||||
// -c is a git global flag that takes the next token as its argument
|
||||
if (t === '-c' || t === '-C' || t === '--work-tree' || t === '--git-dir' ||
|
||||
t === '--namespace' || t === '--super-prefix') {
|
||||
expectFlagArg = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
onlyFlagsAndArgs = false;
|
||||
break;
|
||||
}
|
||||
if (!onlyFlagsAndArgs) { searchPos = cmdIdx + 1; continue; }
|
||||
|
||||
if (cmdIdx < bestIdx) {
|
||||
bestIdx = cmdIdx;
|
||||
bestCmd = cmd;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestCmd) {
|
||||
return { command: bestCmd, offset: bestIdx + bestCmd.length };
|
||||
}
|
||||
|
||||
start = git.idx + git.len;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the input contains a --no-verify flag for a specific git command.
|
||||
* Only inspects the portion of the input starting at `offset` (the position
|
||||
* right after the detected subcommand keyword) so that flags belonging to
|
||||
* earlier commands in a chain are not falsely matched.
|
||||
*/
|
||||
function hasNoVerifyFlag(input, command, offset) {
|
||||
const region = input.slice(offset);
|
||||
if (/--no-verify\b/.test(region)) return true;
|
||||
|
||||
// For commit, -n is shorthand for --no-verify
|
||||
if (command === 'commit') {
|
||||
if (/\s-n(?:\s|$)/.test(region) || /\s-n[a-zA-Z]/.test(region)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the input contains a -c core.hooksPath= override.
|
||||
*/
|
||||
function hasHooksPathOverride(input) {
|
||||
return /-c\s+["']?core\.hooksPath\s*=/.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a command string for git hook bypass attempts.
|
||||
*/
|
||||
function checkCommand(input) {
|
||||
const detected = detectGitCommand(input);
|
||||
if (!detected) return { blocked: false };
|
||||
|
||||
const { command: gitCommand, offset } = detected;
|
||||
|
||||
if (hasNoVerifyFlag(input, gitCommand, offset)) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: `BLOCKED: --no-verify flag is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasHooksPathOverride(input)) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: `BLOCKED: Overriding core.hooksPath is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the command string from hook input (JSON or plain text).
|
||||
*/
|
||||
function extractCommand(rawInput) {
|
||||
const trimmed = rawInput.trim();
|
||||
if (!trimmed.startsWith('{')) return trimmed;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== 'object' || parsed === null) return trimmed;
|
||||
|
||||
// Claude Code format: { tool_input: { command: "..." } }
|
||||
const cmd = parsed.tool_input?.command;
|
||||
if (typeof cmd === 'string') return cmd;
|
||||
|
||||
// Generic JSON formats
|
||||
for (const key of ['command', 'cmd', 'input', 'shell', 'script']) {
|
||||
if (typeof parsed[key] === 'string') return parsed[key];
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exportable run() for in-process execution via run-with-flags.js.
|
||||
*/
|
||||
function run(rawInput) {
|
||||
const command = extractCommand(rawInput);
|
||||
const result = checkCommand(command);
|
||||
|
||||
if (result.blocked) {
|
||||
return {
|
||||
exitCode: 2,
|
||||
stderr: result.reason,
|
||||
};
|
||||
}
|
||||
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
|
||||
// Stdin fallback for spawnSync execution — only when invoked directly, not via require()
|
||||
if (require.main === module) {
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const command = extractCommand(raw);
|
||||
const result = checkCommand(command);
|
||||
|
||||
if (result.blocked) {
|
||||
process.stderr.write(result.reason + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
});
|
||||
}
|
||||
@@ -8,11 +8,8 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const {
|
||||
ensureDir,
|
||||
appendFile,
|
||||
getClaudeDir,
|
||||
} = require('../lib/utils');
|
||||
const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils');
|
||||
const { estimateCost } = require('../lib/cost-estimate');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let raw = '';
|
||||
@@ -22,23 +19,6 @@ function toNumber(value) {
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function estimateCost(model, inputTokens, outputTokens) {
|
||||
// Approximate per-1M-token blended rates. Conservative defaults.
|
||||
const table = {
|
||||
'haiku': { in: 0.8, out: 4.0 },
|
||||
'sonnet': { in: 3.0, out: 15.0 },
|
||||
'opus': { in: 15.0, out: 75.0 },
|
||||
};
|
||||
|
||||
const normalized = String(model || '').toLowerCase();
|
||||
let rates = table.sonnet;
|
||||
if (normalized.includes('haiku')) rates = table.haiku;
|
||||
if (normalized.includes('opus')) rates = table.opus;
|
||||
|
||||
const cost = (inputTokens / 1_000_000) * rates.in + (outputTokens / 1_000_000) * rates.out;
|
||||
return Math.round(cost * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
@@ -55,7 +35,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);
|
||||
@@ -66,7 +46,7 @@ process.stdin.on('end', () => {
|
||||
model,
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
estimated_cost_usd: estimateCost(model, inputTokens, outputTokens),
|
||||
estimated_cost_usd: estimateCost(model, inputTokens, outputTokens)
|
||||
};
|
||||
|
||||
appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`);
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ECC Context Monitor — PostToolUse hook
|
||||
*
|
||||
* Reads bridge file from ecc-metrics-bridge.js and injects agent-facing
|
||||
* warnings when thresholds are crossed: context exhaustion, high cost,
|
||||
* scope creep, or tool loops.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { sanitizeSessionId, readBridge } = require('../lib/session-bridge');
|
||||
|
||||
const CONTEXT_WARNING_PCT = 35;
|
||||
const CONTEXT_CRITICAL_PCT = 25;
|
||||
const COST_NOTICE_USD = 5;
|
||||
const COST_WARNING_USD = 10;
|
||||
const COST_CRITICAL_USD = 50;
|
||||
const FILES_WARNING_COUNT = 20;
|
||||
const LOOP_THRESHOLD = 3;
|
||||
const STALE_SECONDS = 60;
|
||||
const DEBOUNCE_CALLS = 5;
|
||||
|
||||
/**
|
||||
* Get debounce state file path.
|
||||
* @param {string} sessionId
|
||||
* @returns {string}
|
||||
*/
|
||||
function getWarnPath(sessionId) {
|
||||
return path.join(os.tmpdir(), `ecc-ctx-warn-${sessionId}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read debounce state.
|
||||
* @param {string} sessionId
|
||||
* @returns {object}
|
||||
*/
|
||||
function readWarnState(sessionId) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(getWarnPath(sessionId), 'utf8'));
|
||||
} catch {
|
||||
return { callsSinceWarn: 0, lastSeverity: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write debounce state.
|
||||
* @param {string} sessionId
|
||||
* @param {object} state
|
||||
*/
|
||||
function writeWarnState(sessionId, state) {
|
||||
fs.writeFileSync(getWarnPath(sessionId), JSON.stringify(state), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect tool loops from recent_tools ring buffer.
|
||||
* @param {Array} recentTools
|
||||
* @returns {{detected: boolean, tool: string, count: number}}
|
||||
*/
|
||||
function detectLoop(recentTools) {
|
||||
if (!Array.isArray(recentTools) || recentTools.length < LOOP_THRESHOLD) {
|
||||
return { detected: false, tool: '', count: 0 };
|
||||
}
|
||||
const counts = {};
|
||||
for (const entry of recentTools) {
|
||||
const key = `${entry.tool}:${entry.hash}`;
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
}
|
||||
for (const [key, count] of Object.entries(counts)) {
|
||||
if (count >= LOOP_THRESHOLD) {
|
||||
return { detected: true, tool: key.split(':')[0], count };
|
||||
}
|
||||
}
|
||||
return { detected: false, tool: '', count: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate all warning conditions against bridge data.
|
||||
* Returns array of {severity, type, message} sorted by severity desc.
|
||||
*/
|
||||
function evaluateConditions(bridge) {
|
||||
const warnings = [];
|
||||
const remaining = bridge.context_remaining_pct;
|
||||
|
||||
// Context warnings (skip if no context data)
|
||||
if (remaining != null) {
|
||||
if (remaining <= CONTEXT_CRITICAL_PCT) {
|
||||
warnings.push({
|
||||
severity: 3,
|
||||
type: 'context',
|
||||
message:
|
||||
`CONTEXT CRITICAL: ${remaining}% remaining. Context nearly exhausted. ` +
|
||||
'Inform the user that context is low and ask how they want to proceed. ' +
|
||||
'Do NOT autonomously save state or write handoff files unless the user asks.'
|
||||
});
|
||||
} else if (remaining <= CONTEXT_WARNING_PCT) {
|
||||
warnings.push({
|
||||
severity: 2,
|
||||
type: 'context',
|
||||
message: `CONTEXT WARNING: ${remaining}% remaining. ` + 'Be aware that context is getting limited. Avoid starting new complex work.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cost warnings
|
||||
const cost = bridge.total_cost_usd || 0;
|
||||
if (cost > COST_CRITICAL_USD) {
|
||||
warnings.push({
|
||||
severity: 3,
|
||||
type: 'cost',
|
||||
message: `COST CRITICAL: Session cost is $${cost.toFixed(2)}. ` + 'Stop and inform the user about high cost before continuing.'
|
||||
});
|
||||
} else if (cost > COST_WARNING_USD) {
|
||||
warnings.push({
|
||||
severity: 2,
|
||||
type: 'cost',
|
||||
message: `COST WARNING: Session cost is $${cost.toFixed(2)}. ` + 'Review whether the current approach justifies the expense.'
|
||||
});
|
||||
} else if (cost > COST_NOTICE_USD) {
|
||||
warnings.push({
|
||||
severity: 1,
|
||||
type: 'cost',
|
||||
message: `COST NOTICE: Session cost is $${cost.toFixed(2)}. ` + 'Consider whether the current approach is efficient.'
|
||||
});
|
||||
}
|
||||
|
||||
// File scope warning
|
||||
const fileCount = bridge.files_modified_count || 0;
|
||||
if (fileCount > FILES_WARNING_COUNT) {
|
||||
warnings.push({
|
||||
severity: 2,
|
||||
type: 'scope',
|
||||
message: `SCOPE WARNING: ${fileCount} files modified this session. ` + 'Consider whether changes are too scattered.'
|
||||
});
|
||||
}
|
||||
|
||||
// Loop detection
|
||||
const loop = detectLoop(bridge.recent_tools);
|
||||
if (loop.detected) {
|
||||
warnings.push({
|
||||
severity: 2,
|
||||
type: 'loop',
|
||||
message: `LOOP WARNING: Tool '${loop.tool}' called ${loop.count} times ` + 'with same parameters in last 5 calls. This may indicate a stuck loop.'
|
||||
});
|
||||
}
|
||||
|
||||
return warnings.sort((a, b) => b.severity - a.severity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map numeric severity to label.
|
||||
*/
|
||||
function severityLabel(n) {
|
||||
if (n >= 3) return 'critical';
|
||||
if (n >= 2) return 'warning';
|
||||
return 'notice';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} rawInput - Raw JSON string from stdin
|
||||
* @returns {string} JSON output with additionalContext or pass-through
|
||||
*/
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = rawInput.trim() ? JSON.parse(rawInput) : {};
|
||||
|
||||
const sessionId = sanitizeSessionId(input.session_id) || sanitizeSessionId(process.env.ECC_SESSION_ID) || sanitizeSessionId(process.env.CLAUDE_SESSION_ID);
|
||||
|
||||
if (!sessionId) return rawInput;
|
||||
|
||||
const bridge = readBridge(sessionId);
|
||||
if (!bridge) return rawInput;
|
||||
|
||||
// Stale check for context warnings
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const lastTs = bridge.last_timestamp ? Math.floor(new Date(bridge.last_timestamp).getTime() / 1000) : 0;
|
||||
const isStale = lastTs > 0 && now - lastTs > STALE_SECONDS;
|
||||
|
||||
// If bridge is stale, null out context data (still check cost/scope/loop)
|
||||
const evalBridge = isStale ? { ...bridge, context_remaining_pct: null } : bridge;
|
||||
|
||||
const warnings = evaluateConditions(evalBridge);
|
||||
if (warnings.length === 0) return rawInput;
|
||||
|
||||
// Debounce logic
|
||||
const warnState = readWarnState(sessionId);
|
||||
warnState.callsSinceWarn = (warnState.callsSinceWarn || 0) + 1;
|
||||
|
||||
const topSeverity = severityLabel(warnings[0].severity);
|
||||
const severityEscalated = topSeverity === 'critical' && warnState.lastSeverity !== 'critical';
|
||||
|
||||
const isFirst = !warnState.lastSeverity;
|
||||
if (!isFirst && warnState.callsSinceWarn < DEBOUNCE_CALLS && !severityEscalated) {
|
||||
writeWarnState(sessionId, warnState);
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
// Reset debounce, emit warning
|
||||
warnState.callsSinceWarn = 0;
|
||||
warnState.lastSeverity = topSeverity;
|
||||
writeWarnState(sessionId, warnState);
|
||||
|
||||
// Combine top 2 warnings
|
||||
const message = warnings
|
||||
.slice(0, 2)
|
||||
.map(w => w.message)
|
||||
.join('\n');
|
||||
|
||||
const output = {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PostToolUse',
|
||||
additionalContext: message
|
||||
}
|
||||
};
|
||||
|
||||
return JSON.stringify(output);
|
||||
} catch {
|
||||
// Never block tool execution
|
||||
return rawInput;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
let data = '';
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length);
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
process.stdout.write(run(data));
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run, evaluateConditions, detectLoop, severityLabel };
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ECC Metrics Bridge — PostToolUse hook
|
||||
*
|
||||
* Maintains a running session aggregate in /tmp/ecc-metrics-{session}.json.
|
||||
* This bridge file is read by ecc-statusline.js and ecc-context-monitor.js,
|
||||
* avoiding the need to scan large JSONL logs on every invocation.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { estimateCost } = require('../lib/cost-estimate');
|
||||
const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
|
||||
const { getClaudeDir } = require('../lib/utils');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
const MAX_FILES_TRACKED = 200;
|
||||
const RECENT_TOOLS_SIZE = 5;
|
||||
|
||||
function toNumber(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash tool call for loop detection.
|
||||
* Uses tool name + a key parameter (file_path for Edit/Write, first 80 chars of command for Bash).
|
||||
*/
|
||||
function hashToolCall(toolName, toolInput) {
|
||||
const name = String(toolName || '');
|
||||
let key = '';
|
||||
if (name === 'Bash') {
|
||||
key = String(toolInput?.command || '').slice(0, 80);
|
||||
} else {
|
||||
key = String(toolInput?.file_path || '');
|
||||
}
|
||||
return crypto.createHash('sha256').update(`${name}:${key}`).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract modified file paths from tool input.
|
||||
*/
|
||||
function extractFilePaths(toolName, toolInput) {
|
||||
const paths = [];
|
||||
if (!toolInput || typeof toolInput !== 'object') return paths;
|
||||
|
||||
const fp = toolInput.file_path;
|
||||
if (fp && typeof fp === 'string') paths.push(fp);
|
||||
|
||||
const edits = toolInput.edits;
|
||||
if (Array.isArray(edits)) {
|
||||
for (const edit of edits) {
|
||||
if (edit?.file_path && typeof edit.file_path === 'string') {
|
||||
paths.push(edit.file_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read cumulative cost for a session from the tail of costs.jsonl.
|
||||
* Reads last 8KB to avoid scanning entire file.
|
||||
*/
|
||||
function readSessionCost(sessionId) {
|
||||
try {
|
||||
const costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl');
|
||||
const stat = fs.statSync(costsPath);
|
||||
const readSize = Math.min(stat.size, 8192);
|
||||
const fd = fs.openSync(costsPath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(readSize);
|
||||
fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
|
||||
const lines = buf.toString('utf8').split('\n').filter(Boolean);
|
||||
|
||||
let totalCost = 0;
|
||||
let totalIn = 0;
|
||||
let totalOut = 0;
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const row = JSON.parse(line);
|
||||
if (row.session_id === sessionId || row.session_id === 'default') {
|
||||
totalCost += toNumber(row.estimated_cost_usd);
|
||||
totalIn += toNumber(row.input_tokens);
|
||||
totalOut += toNumber(row.output_tokens);
|
||||
}
|
||||
} catch {
|
||||
/* skip malformed lines */
|
||||
}
|
||||
}
|
||||
return { totalCost, totalIn, totalOut };
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return { totalCost: 0, totalIn: 0, totalOut: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} rawInput - Raw JSON string from stdin
|
||||
* @returns {string} Pass-through
|
||||
*/
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = rawInput.trim() ? JSON.parse(rawInput) : {};
|
||||
const toolName = String(input.tool_name || '');
|
||||
const toolInput = input.tool_input || {};
|
||||
|
||||
const sessionId = sanitizeSessionId(input.session_id) || sanitizeSessionId(process.env.ECC_SESSION_ID) || sanitizeSessionId(process.env.CLAUDE_SESSION_ID);
|
||||
|
||||
if (!sessionId) return rawInput;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const bridge = readBridge(sessionId) || {
|
||||
session_id: sessionId,
|
||||
total_cost_usd: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
tool_count: 0,
|
||||
files_modified_count: 0,
|
||||
files_modified: [],
|
||||
recent_tools: [],
|
||||
first_timestamp: now,
|
||||
last_timestamp: now,
|
||||
context_remaining_pct: null
|
||||
};
|
||||
|
||||
// Increment tool count
|
||||
bridge.tool_count = (bridge.tool_count || 0) + 1;
|
||||
bridge.last_timestamp = now;
|
||||
if (!bridge.first_timestamp) bridge.first_timestamp = now;
|
||||
|
||||
// Track modified files (Write/Edit/MultiEdit only)
|
||||
const isWriteOp = /^(Write|Edit|MultiEdit)$/i.test(toolName);
|
||||
if (isWriteOp) {
|
||||
const newPaths = extractFilePaths(toolName, toolInput);
|
||||
const existing = new Set(bridge.files_modified || []);
|
||||
for (const p of newPaths) {
|
||||
if (existing.size < MAX_FILES_TRACKED && !existing.has(p)) {
|
||||
existing.add(p);
|
||||
}
|
||||
}
|
||||
bridge.files_modified = [...existing];
|
||||
bridge.files_modified_count = existing.size;
|
||||
}
|
||||
|
||||
// Ring buffer for loop detection
|
||||
const recent = bridge.recent_tools || [];
|
||||
recent.push({ tool: toolName, hash: hashToolCall(toolName, toolInput) });
|
||||
if (recent.length > RECENT_TOOLS_SIZE) recent.shift();
|
||||
bridge.recent_tools = recent;
|
||||
|
||||
// Update cost from costs.jsonl tail
|
||||
const envSessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default');
|
||||
const costs = readSessionCost(envSessionId);
|
||||
bridge.total_cost_usd = Math.round(costs.totalCost * 1e6) / 1e6;
|
||||
bridge.total_input_tokens = costs.totalIn;
|
||||
bridge.total_output_tokens = costs.totalOut;
|
||||
|
||||
writeBridgeAtomic(sessionId, bridge);
|
||||
} catch {
|
||||
// Never block tool execution
|
||||
}
|
||||
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length);
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
process.stdout.write(run(data));
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run, hashToolCall, extractFilePaths, readSessionCost };
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ECC Statusline — statusLine command
|
||||
*
|
||||
* Displays: model | task | $cost Nt Nf Nm | dir ██░░ N%
|
||||
*
|
||||
* Registered in settings.json under "statusLine", not in hooks.json.
|
||||
* Reads bridge file from ecc-metrics-bridge.js and stdin from Claude Code runtime.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
|
||||
|
||||
const AUTO_COMPACT_BUFFER_PCT = 16.5;
|
||||
|
||||
/**
|
||||
* Format duration from ISO timestamp to now.
|
||||
* @param {string} isoTimestamp
|
||||
* @returns {string} e.g. "5s", "12m", "1h23m"
|
||||
*/
|
||||
function formatDuration(isoTimestamp) {
|
||||
if (!isoTimestamp) return '?';
|
||||
const elapsed = Math.floor((Date.now() - new Date(isoTimestamp).getTime()) / 1000);
|
||||
if (elapsed < 0) return '?';
|
||||
if (elapsed < 60) return `${elapsed}s`;
|
||||
const mins = Math.floor(elapsed / 60);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
const remMins = mins % 60;
|
||||
return remMins > 0 ? `${hours}h${remMins}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context progress bar with ANSI colors.
|
||||
* @param {number} remaining - Raw remaining percentage from Claude Code
|
||||
* @returns {string} Colored bar string
|
||||
*/
|
||||
function buildContextBar(remaining) {
|
||||
if (remaining == null) return '';
|
||||
|
||||
const usableRemaining = Math.max(0, ((remaining - AUTO_COMPACT_BUFFER_PCT) / (100 - AUTO_COMPACT_BUFFER_PCT)) * 100);
|
||||
const used = Math.max(0, Math.min(100, Math.round(100 - usableRemaining)));
|
||||
|
||||
const filled = Math.floor(used / 10);
|
||||
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled);
|
||||
|
||||
if (used < 50) return ` \x1b[32m${bar} ${used}%\x1b[0m`;
|
||||
if (used < 65) return ` \x1b[33m${bar} ${used}%\x1b[0m`;
|
||||
if (used < 80) return ` \x1b[38;5;208m${bar} ${used}%\x1b[0m`;
|
||||
return ` \x1b[5;31m${bar} ${used}%\x1b[0m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read current in-progress task from todos directory.
|
||||
* @param {string} sessionId
|
||||
* @returns {string} Task activeForm text or empty string
|
||||
*/
|
||||
function readCurrentTask(sessionId) {
|
||||
try {
|
||||
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
||||
const todosDir = path.join(claudeDir, 'todos');
|
||||
if (!fs.existsSync(todosDir)) return '';
|
||||
|
||||
const files = fs
|
||||
.readdirSync(todosDir)
|
||||
.filter(f => f.startsWith(sessionId) && f.includes('-agent-') && f.endsWith('.json'))
|
||||
.map(f => ({ name: f, mtime: fs.statSync(path.join(todosDir, f)).mtime }))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
if (files.length === 0) return '';
|
||||
|
||||
const todos = JSON.parse(fs.readFileSync(path.join(todosDir, files[0].name), 'utf8'));
|
||||
const inProgress = todos.find(t => t.status === 'in_progress');
|
||||
return inProgress?.activeForm || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function runStatusline() {
|
||||
let input = '';
|
||||
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => (input += chunk));
|
||||
process.stdin.on('end', () => {
|
||||
clearTimeout(stdinTimeout);
|
||||
try {
|
||||
const data = JSON.parse(input);
|
||||
const model = data.model?.display_name || 'Claude';
|
||||
const dir = data.workspace?.current_dir || process.cwd();
|
||||
const session = data.session_id || '';
|
||||
const remaining = data.context_window?.remaining_percentage;
|
||||
|
||||
const sessionId = sanitizeSessionId(session);
|
||||
const bridge = sessionId ? readBridge(sessionId) : null;
|
||||
|
||||
// Write context % back to bridge for context-monitor
|
||||
if (sessionId && bridge && remaining != null) {
|
||||
bridge.context_remaining_pct = remaining;
|
||||
try {
|
||||
writeBridgeAtomic(sessionId, bridge);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
// Current task
|
||||
const task = session ? readCurrentTask(session) : '';
|
||||
|
||||
// Metrics from bridge
|
||||
let metricsStr = '';
|
||||
if (bridge) {
|
||||
const parts = [];
|
||||
if (bridge.total_cost_usd > 0) {
|
||||
parts.push(`$${bridge.total_cost_usd.toFixed(2)}`);
|
||||
}
|
||||
if (bridge.tool_count > 0) {
|
||||
parts.push(`${bridge.tool_count}t`);
|
||||
}
|
||||
if (bridge.files_modified_count > 0) {
|
||||
parts.push(`${bridge.files_modified_count}f`);
|
||||
}
|
||||
const dur = formatDuration(bridge.first_timestamp);
|
||||
if (dur !== '?') {
|
||||
parts.push(dur);
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
metricsStr = `\x1b[36m${parts.join(' ')}\x1b[0m`;
|
||||
}
|
||||
}
|
||||
|
||||
// Context bar
|
||||
const ctx = buildContextBar(remaining);
|
||||
|
||||
// Build output
|
||||
const dirname = path.basename(dir);
|
||||
const segments = [`\x1b[2m${model}\x1b[0m`];
|
||||
|
||||
if (task) {
|
||||
segments.push(`\x1b[1m${task}\x1b[0m`);
|
||||
}
|
||||
if (metricsStr) {
|
||||
segments.push(metricsStr);
|
||||
}
|
||||
segments.push(`\x1b[2m${dirname}\x1b[0m`);
|
||||
|
||||
process.stdout.write(segments.join(' \x1b[2m\u2502\x1b[0m ') + ctx);
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { formatDuration, buildContextBar, readCurrentTask };
|
||||
|
||||
if (require.main === module) runStatusline();
|
||||
@@ -0,0 +1,399 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PreToolUse Hook: GateGuard Fact-Forcing Gate
|
||||
*
|
||||
* Forces Claude to investigate before editing files or running commands.
|
||||
* Instead of asking "are you sure?" (which LLMs always answer "yes"),
|
||||
* this hook demands concrete facts: importers, public API, data schemas.
|
||||
*
|
||||
* The act of investigation creates awareness that self-evaluation never did.
|
||||
*
|
||||
* Gates:
|
||||
* - Edit/Write: list importers, affected API, verify data schemas, quote instruction
|
||||
* - Bash (destructive): list targets, rollback plan, quote instruction
|
||||
* - Bash (routine): quote current instruction (once per session)
|
||||
*
|
||||
* Compatible with run-with-flags.js via module.exports.run().
|
||||
* Cross-platform (Windows, macOS, Linux).
|
||||
*
|
||||
* Full package with config support: pip install gateguard-ai
|
||||
* Repo: https://github.com/zunoworks/gateguard
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Session state — scoped per session to avoid cross-session races.
|
||||
const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard');
|
||||
let activeStateFile = null;
|
||||
|
||||
// State expires after 30 minutes of inactivity
|
||||
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const READ_HEARTBEAT_MS = 60 * 1000;
|
||||
|
||||
// Maximum checked entries to prevent unbounded growth
|
||||
const MAX_CHECKED_ENTRIES = 500;
|
||||
const MAX_SESSION_KEYS = 50;
|
||||
const ROUTINE_BASH_SESSION_KEY = '__bash_session__';
|
||||
|
||||
const DESTRUCTIVE_BASH = /\b(rm\s+-rf|git\s+reset\s+--hard|git\s+checkout\s+--|git\s+clean\s+-f|drop\s+table|delete\s+from|truncate|git\s+push\s+--force|dd\s+if=)\b/i;
|
||||
|
||||
// --- State management (per-session, atomic writes, bounded) ---
|
||||
|
||||
function sanitizeSessionKey(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sanitized = raw.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
if (sanitized && sanitized.length <= 64) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
return hashSessionKey('sid', raw);
|
||||
}
|
||||
|
||||
function hashSessionKey(prefix, value) {
|
||||
return `${prefix}-${crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 24)}`;
|
||||
}
|
||||
|
||||
function resolveSessionKey(data) {
|
||||
const directCandidates = [
|
||||
data && data.session_id,
|
||||
data && data.sessionId,
|
||||
data && data.session && data.session.id,
|
||||
process.env.CLAUDE_SESSION_ID,
|
||||
process.env.ECC_SESSION_ID,
|
||||
];
|
||||
|
||||
for (const candidate of directCandidates) {
|
||||
const sanitized = sanitizeSessionKey(candidate);
|
||||
if (sanitized) {
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
const transcriptPath = (data && (data.transcript_path || data.transcriptPath)) || process.env.CLAUDE_TRANSCRIPT_PATH;
|
||||
if (transcriptPath && String(transcriptPath).trim()) {
|
||||
return hashSessionKey('tx', path.resolve(String(transcriptPath).trim()));
|
||||
}
|
||||
|
||||
const projectFingerprint = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
||||
return hashSessionKey('proj', path.resolve(projectFingerprint));
|
||||
}
|
||||
|
||||
function getStateFile(data) {
|
||||
if (!activeStateFile) {
|
||||
const sessionKey = resolveSessionKey(data);
|
||||
activeStateFile = path.join(STATE_DIR, `state-${sessionKey}.json`);
|
||||
}
|
||||
return activeStateFile;
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
const stateFile = getStateFile();
|
||||
try {
|
||||
if (fs.existsSync(stateFile)) {
|
||||
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
const lastActive = state.last_active || 0;
|
||||
if (Date.now() - lastActive > SESSION_TIMEOUT_MS) {
|
||||
try { fs.unlinkSync(stateFile); } catch (_) { /* ignore */ }
|
||||
return { checked: [], last_active: Date.now() };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
return { checked: [], last_active: Date.now() };
|
||||
}
|
||||
|
||||
function pruneCheckedEntries(checked) {
|
||||
if (checked.length <= MAX_CHECKED_ENTRIES) {
|
||||
return checked;
|
||||
}
|
||||
|
||||
const preserved = checked.includes(ROUTINE_BASH_SESSION_KEY) ? [ROUTINE_BASH_SESSION_KEY] : [];
|
||||
const sessionKeys = checked.filter(k => k.startsWith('__') && k !== ROUTINE_BASH_SESSION_KEY);
|
||||
const fileKeys = checked.filter(k => !k.startsWith('__'));
|
||||
const remainingSessionSlots = Math.max(MAX_SESSION_KEYS - preserved.length, 0);
|
||||
const cappedSession = sessionKeys.slice(-remainingSessionSlots);
|
||||
const remainingFileSlots = Math.max(MAX_CHECKED_ENTRIES - preserved.length - cappedSession.length, 0);
|
||||
const cappedFiles = fileKeys.slice(-remainingFileSlots);
|
||||
return [...preserved, ...cappedSession, ...cappedFiles];
|
||||
}
|
||||
|
||||
function saveState(state) {
|
||||
const stateFile = getStateFile();
|
||||
let tmpFile = null;
|
||||
try {
|
||||
state.last_active = Date.now();
|
||||
state.checked = pruneCheckedEntries(state.checked);
|
||||
fs.mkdirSync(STATE_DIR, { recursive: true });
|
||||
// Atomic write: temp file + rename prevents partial reads
|
||||
tmpFile = stateFile + '.tmp.' + process.pid;
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), 'utf8');
|
||||
try {
|
||||
fs.renameSync(tmpFile, stateFile);
|
||||
} catch (error) {
|
||||
if (error && (error.code === 'EEXIST' || error.code === 'EPERM')) {
|
||||
try { fs.unlinkSync(stateFile); } catch (_) { /* ignore */ }
|
||||
fs.renameSync(tmpFile, stateFile);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
if (tmpFile) {
|
||||
try { fs.unlinkSync(tmpFile); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function markChecked(key) {
|
||||
const state = loadState();
|
||||
if (!state.checked.includes(key)) {
|
||||
state.checked.push(key);
|
||||
saveState(state);
|
||||
}
|
||||
}
|
||||
|
||||
function isChecked(key) {
|
||||
const state = loadState();
|
||||
const found = state.checked.includes(key);
|
||||
if (found && Date.now() - (state.last_active || 0) > READ_HEARTBEAT_MS) {
|
||||
saveState(state);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// Prune stale session files older than 1 hour
|
||||
(function pruneStaleFiles() {
|
||||
try {
|
||||
const files = fs.readdirSync(STATE_DIR);
|
||||
const now = Date.now();
|
||||
for (const f of files) {
|
||||
if (!f.startsWith('state-') || !f.endsWith('.json')) continue;
|
||||
const fp = path.join(STATE_DIR, f);
|
||||
try {
|
||||
const stat = fs.statSync(fp);
|
||||
if (now - stat.mtimeMs > SESSION_TIMEOUT_MS * 2) {
|
||||
fs.unlinkSync(fp);
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore files that disappear between readdir/stat/unlink.
|
||||
}
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
})();
|
||||
|
||||
// --- Sanitize file path against injection ---
|
||||
|
||||
function sanitizePath(filePath) {
|
||||
// Strip control chars (including null), bidi overrides, and newlines
|
||||
let sanitized = '';
|
||||
for (const char of String(filePath || '')) {
|
||||
const code = char.codePointAt(0);
|
||||
const isAsciiControl = code <= 0x1f || code === 0x7f;
|
||||
const isBidiOverride = (code >= 0x200e && code <= 0x200f) || (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069);
|
||||
sanitized += (isAsciiControl || isBidiOverride) ? ' ' : char;
|
||||
}
|
||||
return sanitized.trim().slice(0, 500);
|
||||
}
|
||||
|
||||
function normalizeForMatch(value) {
|
||||
return String(value || '').replace(/\\/g, '/').toLowerCase();
|
||||
}
|
||||
|
||||
function isClaudeSettingsPath(filePath) {
|
||||
const normalized = normalizeForMatch(filePath);
|
||||
return /(^|\/)\.claude\/settings(?:\.[^/]+)?\.json$/.test(normalized);
|
||||
}
|
||||
|
||||
function isReadOnlyGitIntrospection(command) {
|
||||
const trimmed = String(command || '').trim();
|
||||
if (!trimmed || /[\r\n;&|><`$()]/.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tokens = trimmed.split(/\s+/);
|
||||
if (tokens[0] !== 'git' || tokens.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const subcommand = tokens[1].toLowerCase();
|
||||
const args = tokens.slice(2);
|
||||
|
||||
if (subcommand === 'status') {
|
||||
return args.every(arg => ['--porcelain', '--short', '--branch'].includes(arg));
|
||||
}
|
||||
|
||||
if (subcommand === 'diff') {
|
||||
return args.length <= 1 && args.every(arg => ['--name-only', '--name-status'].includes(arg));
|
||||
}
|
||||
|
||||
if (subcommand === 'log') {
|
||||
return args.every(arg => arg === '--oneline' || /^--max-count=\d+$/.test(arg));
|
||||
}
|
||||
|
||||
if (subcommand === 'show') {
|
||||
return args.length === 1 && !args[0].startsWith('--') && /^[a-zA-Z0-9._:/-]+$/.test(args[0]);
|
||||
}
|
||||
|
||||
if (subcommand === 'branch') {
|
||||
return args.length === 1 && args[0] === '--show-current';
|
||||
}
|
||||
|
||||
if (subcommand === 'rev-parse') {
|
||||
return args.length === 2 && args[0] === '--abbrev-ref' && /^head$/i.test(args[1]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Gate messages ---
|
||||
|
||||
function editGateMsg(filePath) {
|
||||
const safe = sanitizePath(filePath);
|
||||
return [
|
||||
'[Fact-Forcing Gate]',
|
||||
'',
|
||||
`Before editing ${safe}, present these facts:`,
|
||||
'',
|
||||
'1. List ALL files that import/require this file (use Grep)',
|
||||
'2. List the public functions/classes affected by this change',
|
||||
'3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
|
||||
'4. Quote the user\'s current instruction verbatim',
|
||||
'',
|
||||
'Present the facts, then retry the same operation.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function writeGateMsg(filePath) {
|
||||
const safe = sanitizePath(filePath);
|
||||
return [
|
||||
'[Fact-Forcing Gate]',
|
||||
'',
|
||||
`Before creating ${safe}, present these facts:`,
|
||||
'',
|
||||
'1. Name the file(s) and line(s) that will call this new file',
|
||||
'2. Confirm no existing file serves the same purpose (use Glob)',
|
||||
'3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
|
||||
'4. Quote the user\'s current instruction verbatim',
|
||||
'',
|
||||
'Present the facts, then retry the same operation.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function destructiveBashMsg() {
|
||||
return [
|
||||
'[Fact-Forcing Gate]',
|
||||
'',
|
||||
'Destructive command detected. Before running, present:',
|
||||
'',
|
||||
'1. List all files/data this command will modify or delete',
|
||||
'2. Write a one-line rollback procedure',
|
||||
'3. Quote the user\'s current instruction verbatim',
|
||||
'',
|
||||
'Present the facts, then retry the same operation.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function routineBashMsg() {
|
||||
return [
|
||||
'[Fact-Forcing Gate]',
|
||||
'',
|
||||
'Quote the user\'s current instruction verbatim.',
|
||||
'Then retry the same operation.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// --- Deny helper ---
|
||||
|
||||
function denyResult(reason) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: reason
|
||||
}
|
||||
}),
|
||||
exitCode: 0
|
||||
};
|
||||
}
|
||||
|
||||
// --- Core logic (exported for run-with-flags.js) ---
|
||||
|
||||
function run(rawInput) {
|
||||
let data;
|
||||
try {
|
||||
data = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
|
||||
} catch (_) {
|
||||
return rawInput; // allow on parse error
|
||||
}
|
||||
activeStateFile = null;
|
||||
getStateFile(data);
|
||||
|
||||
const rawToolName = data.tool_name || '';
|
||||
const toolInput = data.tool_input || {};
|
||||
// Normalize: case-insensitive matching via lookup map
|
||||
const TOOL_MAP = { 'edit': 'Edit', 'write': 'Write', 'multiedit': 'MultiEdit', 'bash': 'Bash' };
|
||||
const toolName = TOOL_MAP[rawToolName.toLowerCase()] || rawToolName;
|
||||
|
||||
if (toolName === 'Edit' || toolName === 'Write') {
|
||||
const filePath = toolInput.file_path || '';
|
||||
if (!filePath || isClaudeSettingsPath(filePath)) {
|
||||
return rawInput; // allow
|
||||
}
|
||||
|
||||
if (!isChecked(filePath)) {
|
||||
markChecked(filePath);
|
||||
return denyResult(toolName === 'Edit' ? editGateMsg(filePath) : writeGateMsg(filePath));
|
||||
}
|
||||
|
||||
return rawInput; // allow
|
||||
}
|
||||
|
||||
if (toolName === 'MultiEdit') {
|
||||
const edits = toolInput.edits || [];
|
||||
for (const edit of edits) {
|
||||
const filePath = edit.file_path || '';
|
||||
if (filePath && !isClaudeSettingsPath(filePath) && !isChecked(filePath)) {
|
||||
markChecked(filePath);
|
||||
return denyResult(editGateMsg(filePath));
|
||||
}
|
||||
}
|
||||
return rawInput; // allow
|
||||
}
|
||||
|
||||
if (toolName === 'Bash') {
|
||||
const command = toolInput.command || '';
|
||||
if (isReadOnlyGitIntrospection(command)) {
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
if (DESTRUCTIVE_BASH.test(command)) {
|
||||
// Gate destructive commands on first attempt; allow retry after facts presented
|
||||
const key = '__destructive__' + crypto.createHash('sha256').update(command).digest('hex').slice(0, 16);
|
||||
if (!isChecked(key)) {
|
||||
markChecked(key);
|
||||
return denyResult(destructiveBashMsg());
|
||||
}
|
||||
return rawInput; // allow retry after facts presented
|
||||
}
|
||||
|
||||
if (!isChecked(ROUTINE_BASH_SESSION_KEY)) {
|
||||
markChecked(ROUTINE_BASH_SESSION_KEY);
|
||||
return denyResult(routineBashMsg());
|
||||
}
|
||||
|
||||
return rawInput; // allow
|
||||
}
|
||||
|
||||
return rawInput; // allow
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
@@ -24,7 +24,10 @@ const DEFAULT_TTL_MS = 2 * 60 * 1000;
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
const DEFAULT_BACKOFF_MS = 30 * 1000;
|
||||
const MAX_BACKOFF_MS = 10 * 60 * 1000;
|
||||
const HEALTHY_HTTP_CODES = new Set([200, 201, 202, 204, 301, 302, 303, 304, 307, 308, 400, 405]);
|
||||
// The preflight HTTP probe only checks reachability; it does not have access to
|
||||
// Claude Code's stored OAuth bearer token. Treat auth-gated responses as
|
||||
// reachable so the real MCP client can attempt the authenticated call.
|
||||
const HEALTHY_HTTP_CODES = new Set([200, 201, 202, 204, 301, 302, 303, 304, 307, 308, 400, 401, 403, 405]);
|
||||
const RECONNECT_STATUS_CODES = new Set([401, 403, 429, 503]);
|
||||
const FAILURE_PATTERNS = [
|
||||
{ code: 401, pattern: /\b401\b|unauthori[sz]ed|auth(?:entication)?\s+(?:failed|expired|invalid)/i },
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
function readStdinRaw() {
|
||||
try {
|
||||
return fs.readFileSync(0, 'utf8');
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function writeStderr(stderr) {
|
||||
if (typeof stderr === 'string' && stderr.length > 0) {
|
||||
process.stderr.write(stderr);
|
||||
}
|
||||
}
|
||||
|
||||
function passthrough(raw, result) {
|
||||
const stdout = typeof result?.stdout === 'string' ? result.stdout : '';
|
||||
if (stdout) {
|
||||
process.stdout.write(stdout);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(result?.status) || result.status === 0) {
|
||||
process.stdout.write(raw);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTarget(rootDir, relPath) {
|
||||
const resolvedRoot = path.resolve(rootDir);
|
||||
const resolvedTarget = path.resolve(rootDir, relPath);
|
||||
if (
|
||||
resolvedTarget !== resolvedRoot &&
|
||||
!resolvedTarget.startsWith(resolvedRoot + path.sep)
|
||||
) {
|
||||
throw new Error(`Path traversal rejected: ${relPath}`);
|
||||
}
|
||||
return resolvedTarget;
|
||||
}
|
||||
|
||||
function findShellBinary() {
|
||||
const candidates = [];
|
||||
if (process.env.BASH && process.env.BASH.trim()) {
|
||||
candidates.push(process.env.BASH.trim());
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
candidates.push('bash.exe', 'bash');
|
||||
} else {
|
||||
candidates.push('bash', 'sh');
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const probe = spawnSync(candidate, ['-c', ':'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
if (!probe.error) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function spawnNode(rootDir, relPath, raw, args) {
|
||||
return spawnSync(process.execPath, [resolveTarget(rootDir, relPath), ...args], {
|
||||
input: raw,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_PLUGIN_ROOT: rootDir,
|
||||
ECC_PLUGIN_ROOT: rootDir,
|
||||
},
|
||||
cwd: process.cwd(),
|
||||
timeout: 30000,
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
function spawnShell(rootDir, relPath, raw, args) {
|
||||
const shell = findShellBinary();
|
||||
if (!shell) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: '',
|
||||
stderr: '[Hook] shell runtime unavailable; skipping shell-backed hook\n',
|
||||
};
|
||||
}
|
||||
|
||||
return spawnSync(shell, [resolveTarget(rootDir, relPath), ...args], {
|
||||
input: raw,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_PLUGIN_ROOT: rootDir,
|
||||
ECC_PLUGIN_ROOT: rootDir,
|
||||
},
|
||||
cwd: process.cwd(),
|
||||
timeout: 30000,
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [, , mode, relPath, ...args] = process.argv;
|
||||
const raw = readStdinRaw();
|
||||
const rootDir = process.env.CLAUDE_PLUGIN_ROOT || process.env.ECC_PLUGIN_ROOT;
|
||||
|
||||
if (!mode || !relPath || !rootDir) {
|
||||
process.stdout.write(raw);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
if (mode === 'node') {
|
||||
result = spawnNode(rootDir, relPath, raw, args);
|
||||
} else if (mode === 'shell') {
|
||||
result = spawnShell(rootDir, relPath, raw, args);
|
||||
} else {
|
||||
writeStderr(`[Hook] unknown bootstrap mode: ${mode}\n`);
|
||||
process.stdout.write(raw);
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (error) {
|
||||
writeStderr(`[Hook] bootstrap resolution failed: ${error.message}\n`);
|
||||
process.stdout.write(raw);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
passthrough(raw, result);
|
||||
writeStderr(result.stderr);
|
||||
|
||||
if (result.error || result.signal || result.status === null) {
|
||||
const reason = result.error
|
||||
? result.error.message
|
||||
: result.signal
|
||||
? `terminated by signal ${result.signal}`
|
||||
: 'missing exit status';
|
||||
writeStderr(`[Hook] bootstrap execution failed: ${reason}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.exit(Number.isInteger(result.status) ? result.status : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -4,24 +4,46 @@
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
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', () => {
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
|
||||
const cmd = String(input.tool_input?.command || '');
|
||||
if (/(npm run build|pnpm build|yarn build)/.test(cmd)) {
|
||||
console.error('[Hook] Build completed - async analysis running in background');
|
||||
return {
|
||||
stdout: typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput),
|
||||
stderr: '[Hook] Build completed - async analysis running in background',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors and pass through
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
});
|
||||
return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const result = run(raw);
|
||||
if (result && typeof result === 'object') {
|
||||
if (result.stderr) {
|
||||
process.stderr.write(`${result.stderr}\n`);
|
||||
}
|
||||
process.stdout.write(String(result.stdout || ''));
|
||||
process.exitCode = Number.isInteger(result.exitCode) ? result.exitCode : 0;
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(String(result));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
|
||||
@@ -38,8 +38,24 @@ function appendLine(filePath, line) {
|
||||
fs.appendFileSync(filePath, `${line}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function run(rawInput, mode = 'audit') {
|
||||
const config = MODE_CONFIG[mode];
|
||||
|
||||
try {
|
||||
if (config) {
|
||||
const input = String(rawInput || '').trim() ? JSON.parse(String(rawInput)) : {};
|
||||
const command = sanitizeCommand(input.tool_input?.command || '?');
|
||||
appendLine(path.join(os.homedir(), '.claude', config.fileName), config.format(command));
|
||||
}
|
||||
} catch {
|
||||
// Logging must never block the calling hook.
|
||||
}
|
||||
|
||||
return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const config = MODE_CONFIG[process.argv[2]];
|
||||
const mode = process.argv[2];
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
@@ -50,17 +66,7 @@ function main() {
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
if (config) {
|
||||
const input = raw.trim() ? JSON.parse(raw) : {};
|
||||
const command = sanitizeCommand(input.tool_input?.command || '?');
|
||||
appendLine(path.join(os.homedir(), '.claude', config.fileName), config.format(command));
|
||||
}
|
||||
} catch {
|
||||
// Logging must never block the calling hook.
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
process.stdout.write(run(raw, mode));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,5 +75,6 @@ if (require.main === module) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
run,
|
||||
sanitizeCommand,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const { runPostBash } = require('./bash-hook-dispatcher');
|
||||
|
||||
let raw = '';
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const result = runPostBash(raw);
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
process.stdout.write(result.output);
|
||||
process.exitCode = result.exitCode;
|
||||
});
|
||||
@@ -4,17 +4,9 @@
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
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', () => {
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
|
||||
const cmd = String(input.tool_input?.command || '');
|
||||
|
||||
if (/\bgh\s+pr\s+create\b/.test(cmd)) {
|
||||
@@ -24,13 +16,45 @@ process.stdin.on('end', () => {
|
||||
const prUrl = match[0];
|
||||
const repo = prUrl.replace(/https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+/, '$1');
|
||||
const prNum = prUrl.replace(/.+\/pull\/(\d+)/, '$1');
|
||||
console.error(`[Hook] PR created: ${prUrl}`);
|
||||
console.error(`[Hook] To review: gh pr review ${prNum} --repo ${repo}`);
|
||||
return {
|
||||
stdout: typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput),
|
||||
stderr: [
|
||||
`[Hook] PR created: ${prUrl}`,
|
||||
`[Hook] To review: gh pr review ${prNum} --repo ${repo}`,
|
||||
].join('\n'),
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors and pass through
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
});
|
||||
return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const result = run(raw);
|
||||
if (result && typeof result === 'object') {
|
||||
if (result.stderr) {
|
||||
process.stderr.write(`${result.stderr}\n`);
|
||||
}
|
||||
process.stdout.write(String(result.stdout || ''));
|
||||
process.exit(Number.isInteger(result.exitCode) ? result.exitCode : 0);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(String(result));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
|
||||
@@ -380,7 +380,11 @@ function evaluate(rawInput) {
|
||||
}
|
||||
|
||||
function run(rawInput) {
|
||||
return evaluate(rawInput).output;
|
||||
const result = evaluate(rawInput);
|
||||
return {
|
||||
stdout: result.output,
|
||||
exitCode: result.exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
// ── stdin entry point ────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const { runPreBash } = require('./bash-hook-dispatcher');
|
||||
|
||||
let raw = '';
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const result = runPreBash(raw);
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
process.stdout.write(result.output);
|
||||
process.exitCode = result.exitCode;
|
||||
});
|
||||
@@ -4,25 +4,49 @@
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
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', () => {
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
|
||||
const cmd = String(input.tool_input?.command || '');
|
||||
if (/\bgit\s+push\b/.test(cmd)) {
|
||||
console.error('[Hook] Review changes before push...');
|
||||
console.error('[Hook] Continuing with push (remove this hook to add interactive review)');
|
||||
return {
|
||||
stdout: typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput),
|
||||
stderr: [
|
||||
'[Hook] Review changes before push...',
|
||||
'[Hook] Continuing with push (remove this hook to add interactive review)',
|
||||
].join('\n'),
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors and pass through
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
});
|
||||
return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const result = run(raw);
|
||||
if (result && typeof result === 'object') {
|
||||
if (result.stderr) {
|
||||
process.stderr.write(`${result.stderr}\n`);
|
||||
}
|
||||
process.stdout.write(String(result.stdout || ''));
|
||||
process.exitCode = Number.isInteger(result.exitCode) ? result.exitCode : 0;
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(String(result));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
|
||||
@@ -4,17 +4,9 @@
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
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', () => {
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
|
||||
const cmd = String(input.tool_input?.command || '');
|
||||
|
||||
if (
|
||||
@@ -22,12 +14,44 @@ process.stdin.on('end', () => {
|
||||
!process.env.TMUX &&
|
||||
/(npm (install|test)|pnpm (install|test)|yarn (install|test)?|bun (install|test)|cargo build|make\b|docker\b|pytest|vitest|playwright)/.test(cmd)
|
||||
) {
|
||||
console.error('[Hook] Consider running in tmux for session persistence');
|
||||
console.error('[Hook] tmux new -s dev | tmux attach -t dev');
|
||||
return {
|
||||
stdout: typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput),
|
||||
stderr: [
|
||||
'[Hook] Consider running in tmux for session persistence',
|
||||
'[Hook] tmux new -s dev | tmux attach -t dev',
|
||||
].join('\n'),
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors and pass through
|
||||
}
|
||||
|
||||
process.stdout.write(raw);
|
||||
});
|
||||
return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const result = run(raw);
|
||||
if (result && typeof result === 'object') {
|
||||
if (result.stderr) {
|
||||
process.stderr.write(`${result.stderr}\n`);
|
||||
}
|
||||
process.stdout.write(String(result.stdout || ''));
|
||||
process.exitCode = Number.isInteger(result.exitCode) ? result.exitCode : 0;
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(String(result));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
|
||||
@@ -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 || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return truncateSummary(value, 160);
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, 8).map(entry => sanitizeParamValue(entry, depth + 1));
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const output = {};
|
||||
for (const [key, nested] of Object.entries(value).slice(0, 20)) {
|
||||
output[key] = sanitizeParamValue(nested, depth + 1);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
return truncateSummary(String(value), 160);
|
||||
}
|
||||
|
||||
function sanitizeInputParams(toolInput) {
|
||||
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) {
|
||||
return '{}';
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(sanitizeParamValue(toolInput));
|
||||
} catch {
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
function pushPathCandidate(paths, value) {
|
||||
const candidate = String(value || '').trim();
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) {
|
||||
return;
|
||||
}
|
||||
if (!paths.includes(candidate)) {
|
||||
paths.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function pushFileEvent(events, value, action, diffPreview, patchPreview) {
|
||||
const candidate = String(value || '').trim();
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) {
|
||||
return;
|
||||
}
|
||||
const normalizedDiffPreview = typeof diffPreview === 'string' && diffPreview.trim()
|
||||
? diffPreview.trim()
|
||||
: undefined;
|
||||
const normalizedPatchPreview = typeof patchPreview === 'string' && patchPreview.trim()
|
||||
? patchPreview.trim()
|
||||
: undefined;
|
||||
if (!events.some(event =>
|
||||
event.path === candidate
|
||||
&& event.action === action
|
||||
&& (event.diff_preview || undefined) === normalizedDiffPreview
|
||||
&& (event.patch_preview || undefined) === normalizedPatchPreview
|
||||
)) {
|
||||
const event = { path: candidate, action };
|
||||
if (normalizedDiffPreview) {
|
||||
event.diff_preview = normalizedDiffPreview;
|
||||
}
|
||||
if (normalizedPatchPreview) {
|
||||
event.patch_preview = normalizedPatchPreview;
|
||||
}
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeDiffText(value, maxLength = 96) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return '';
|
||||
}
|
||||
return truncateSummary(value, maxLength);
|
||||
}
|
||||
|
||||
function sanitizePatchLines(value, maxLines = 4, maxLineLength = 120) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return stripAnsi(redactSecrets(value))
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, maxLines)
|
||||
.map(line => line.length <= maxLineLength ? line : `${line.slice(0, maxLineLength - 3)}...`);
|
||||
}
|
||||
|
||||
function buildReplacementPreview(oldValue, newValue) {
|
||||
const before = sanitizeDiffText(oldValue);
|
||||
const after = sanitizeDiffText(newValue);
|
||||
if (!before && !after) {
|
||||
return undefined;
|
||||
}
|
||||
if (!before) {
|
||||
return `-> ${after}`;
|
||||
}
|
||||
if (!after) {
|
||||
return `${before} ->`;
|
||||
}
|
||||
return `${before} -> ${after}`;
|
||||
}
|
||||
|
||||
function buildCreationPreview(content) {
|
||||
const normalized = sanitizeDiffText(content);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return `+ ${normalized}`;
|
||||
}
|
||||
|
||||
function buildPatchPreviewFromReplacement(oldValue, newValue) {
|
||||
const beforeLines = sanitizePatchLines(oldValue);
|
||||
const afterLines = sanitizePatchLines(newValue);
|
||||
if (beforeLines.length === 0 && afterLines.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lines = ['@@'];
|
||||
for (const line of beforeLines) {
|
||||
lines.push(`- ${line}`);
|
||||
}
|
||||
for (const line of afterLines) {
|
||||
lines.push(`+ ${line}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildPatchPreviewFromContent(content, prefix) {
|
||||
const lines = sanitizePatchLines(content);
|
||||
if (lines.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return lines.map(line => `${prefix} ${line}`).join('\n');
|
||||
}
|
||||
|
||||
function buildDiffPreviewFromPatchPreview(patchPreview) {
|
||||
if (typeof patchPreview !== 'string' || !patchPreview.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lines = patchPreview
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
const removed = lines.find(line => line.startsWith('- ') || line.startsWith('-'));
|
||||
const added = lines.find(line => line.startsWith('+ ') || line.startsWith('+'));
|
||||
|
||||
if (!removed && !added) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const before = removed ? removed.replace(/^- ?/, '') : '';
|
||||
const after = added ? added.replace(/^\+ ?/, '') : '';
|
||||
if (before && after) {
|
||||
return `${before} -> ${after}`;
|
||||
}
|
||||
if (before) {
|
||||
return `${before} ->`;
|
||||
}
|
||||
return `-> ${after}`;
|
||||
}
|
||||
|
||||
function inferDefaultFileAction(toolName) {
|
||||
const normalized = String(toolName || '').trim().toLowerCase();
|
||||
if (normalized.includes('read')) {
|
||||
return 'read';
|
||||
}
|
||||
if (normalized.includes('write')) {
|
||||
return 'create';
|
||||
}
|
||||
if (normalized.includes('edit')) {
|
||||
return 'modify';
|
||||
}
|
||||
if (normalized.includes('delete') || normalized.includes('remove')) {
|
||||
return 'delete';
|
||||
}
|
||||
if (normalized.includes('move') || normalized.includes('rename')) {
|
||||
return 'move';
|
||||
}
|
||||
return 'touch';
|
||||
}
|
||||
|
||||
function actionForFileKey(toolName, key) {
|
||||
if (key === 'source_path' || key === 'old_file_path') {
|
||||
return 'move';
|
||||
}
|
||||
if (key === 'destination_path' || key === 'new_file_path') {
|
||||
return 'move';
|
||||
}
|
||||
return inferDefaultFileAction(toolName);
|
||||
}
|
||||
|
||||
function collectFilePaths(value, paths) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
collectFilePaths(entry, paths);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
pushPathCandidate(paths, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (FILE_PATH_KEYS.has(key)) {
|
||||
collectFilePaths(nested, paths);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nested && (Array.isArray(nested) || typeof nested === 'object')) {
|
||||
collectFilePaths(nested, paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractFilePaths(toolInput) {
|
||||
const paths = [];
|
||||
if (!toolInput || typeof toolInput !== 'object') {
|
||||
return paths;
|
||||
}
|
||||
collectFilePaths(toolInput, paths);
|
||||
return paths;
|
||||
}
|
||||
|
||||
function fileEventDiffPreview(toolName, value, action) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value.old_string === 'string' || typeof value.new_string === 'string') {
|
||||
return buildReplacementPreview(value.old_string, value.new_string);
|
||||
}
|
||||
|
||||
if (action === 'create') {
|
||||
return buildCreationPreview(value.content || value.file_text || value.text);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function fileEventPatchPreview(value, action) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value.old_string === 'string' || typeof value.new_string === 'string') {
|
||||
return buildPatchPreviewFromReplacement(value.old_string, value.new_string);
|
||||
}
|
||||
|
||||
if (action === 'create') {
|
||||
return buildPatchPreviewFromContent(value.content || value.file_text || value.text, '+');
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
return buildPatchPreviewFromContent(value.content || value.old_string || value.file_text, '-');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function runGit(args, cwd) {
|
||||
const result = spawnSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
timeout: 2500,
|
||||
});
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return String(result.stdout || '').trim();
|
||||
}
|
||||
|
||||
function gitRepoRoot(cwd) {
|
||||
return runGit(['rev-parse', '--show-toplevel'], cwd);
|
||||
}
|
||||
|
||||
const MAX_RELEVANT_PATCH_LINES = 6;
|
||||
|
||||
function candidateGitPaths(repoRoot, filePath) {
|
||||
const resolvedRepoRoot = path.resolve(repoRoot);
|
||||
const candidates = [];
|
||||
const pushCandidate = value => {
|
||||
const candidate = String(value || '').trim();
|
||||
if (!candidate || candidates.includes(candidate)) {
|
||||
return;
|
||||
}
|
||||
candidates.push(candidate);
|
||||
};
|
||||
|
||||
const absoluteCandidates = path.isAbsolute(filePath)
|
||||
? [path.resolve(filePath)]
|
||||
: [
|
||||
path.resolve(resolvedRepoRoot, filePath),
|
||||
path.resolve(process.cwd(), filePath),
|
||||
];
|
||||
|
||||
for (const absolute of absoluteCandidates) {
|
||||
const relative = path.relative(resolvedRepoRoot, absolute);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pushCandidate(relative);
|
||||
pushCandidate(relative.split(path.sep).join('/'));
|
||||
pushCandidate(absolute);
|
||||
pushCandidate(absolute.split(path.sep).join('/'));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function patchPreviewFromGitDiff(repoRoot, pathCandidates) {
|
||||
for (const candidate of pathCandidates) {
|
||||
const patch = runGit(
|
||||
['diff', '--no-ext-diff', '--no-color', '--unified=1', '--', candidate],
|
||||
repoRoot
|
||||
);
|
||||
if (!patch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relevant = patch
|
||||
.split(/\r?\n/)
|
||||
.filter(line =>
|
||||
line.startsWith('@@')
|
||||
|| (line.startsWith('+') && !line.startsWith('+++'))
|
||||
|| (line.startsWith('-') && !line.startsWith('---'))
|
||||
)
|
||||
.slice(0, MAX_RELEVANT_PATCH_LINES);
|
||||
|
||||
if (relevant.length > 0) {
|
||||
return relevant.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function trackedInGit(repoRoot, pathCandidates) {
|
||||
return pathCandidates.some(candidate =>
|
||||
runGit(['ls-files', '--error-unmatch', '--', candidate], repoRoot) !== null
|
||||
);
|
||||
}
|
||||
|
||||
function enrichFileEventFromWorkingTree(toolName, event) {
|
||||
if (!event || typeof event !== 'object' || !event.path) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const repoRoot = gitRepoRoot(process.cwd());
|
||||
if (!repoRoot) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const pathCandidates = candidateGitPaths(repoRoot, event.path);
|
||||
if (pathCandidates.length === 0) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const tool = String(toolName || '').trim().toLowerCase();
|
||||
const tracked = trackedInGit(repoRoot, pathCandidates);
|
||||
const patchPreview = patchPreviewFromGitDiff(repoRoot, pathCandidates) || event.patch_preview;
|
||||
const diffPreview = buildDiffPreviewFromPatchPreview(patchPreview) || event.diff_preview;
|
||||
|
||||
if (tool.includes('write')) {
|
||||
return {
|
||||
...event,
|
||||
action: tracked ? 'modify' : event.action,
|
||||
diff_preview: diffPreview,
|
||||
patch_preview: patchPreview,
|
||||
};
|
||||
}
|
||||
|
||||
if (tracked && patchPreview) {
|
||||
return {
|
||||
...event,
|
||||
diff_preview: diffPreview,
|
||||
patch_preview: patchPreview,
|
||||
};
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
function collectFileEvents(toolName, value, events, key = null, parentValue = null) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
collectFileEvents(toolName, entry, events, key, parentValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (key && FILE_PATH_KEYS.has(key)) {
|
||||
const action = actionForFileKey(toolName, key);
|
||||
pushFileEvent(
|
||||
events,
|
||||
value,
|
||||
action,
|
||||
fileEventDiffPreview(toolName, parentValue, action),
|
||||
fileEventPatchPreview(parentValue, action)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [nestedKey, nested] of Object.entries(value)) {
|
||||
if (FILE_PATH_KEYS.has(nestedKey)) {
|
||||
collectFileEvents(toolName, nested, events, nestedKey, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nested && (Array.isArray(nested) || typeof nested === 'object')) {
|
||||
collectFileEvents(toolName, nested, events, null, nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractFileEvents(toolName, toolInput) {
|
||||
const events = [];
|
||||
if (!toolInput || typeof toolInput !== 'object') {
|
||||
return events;
|
||||
}
|
||||
collectFileEvents(toolName, toolInput, events);
|
||||
return events;
|
||||
}
|
||||
|
||||
function summarizeInput(toolName, toolInput, filePaths) {
|
||||
if (toolName === 'Bash') {
|
||||
return truncateSummary(toolInput?.command || 'bash');
|
||||
}
|
||||
|
||||
if (filePaths.length > 0) {
|
||||
return truncateSummary(`${toolName} ${filePaths.join(', ')}`);
|
||||
}
|
||||
|
||||
if (toolInput && typeof toolInput === 'object') {
|
||||
const shallow = {};
|
||||
for (const [key, value] of Object.entries(toolInput)) {
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
shallow[key] = value;
|
||||
}
|
||||
}
|
||||
const serialized = Object.keys(shallow).length > 0 ? JSON.stringify(shallow) : toolName;
|
||||
return truncateSummary(serialized);
|
||||
}
|
||||
|
||||
return truncateSummary(toolName);
|
||||
}
|
||||
|
||||
function summarizeOutput(toolOutput) {
|
||||
if (toolOutput === null || toolOutput === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof toolOutput === 'string') {
|
||||
return truncateSummary(toolOutput);
|
||||
}
|
||||
|
||||
if (typeof toolOutput === 'object' && typeof toolOutput.output === 'string') {
|
||||
return truncateSummary(toolOutput.output);
|
||||
}
|
||||
|
||||
return truncateSummary(JSON.stringify(toolOutput));
|
||||
}
|
||||
|
||||
function buildActivityRow(input, env = process.env) {
|
||||
const hookEvent = String(env.CLAUDE_HOOK_EVENT_NAME || '').trim();
|
||||
if (hookEvent && hookEvent !== 'PostToolUse') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolName = String(input?.tool_name || '').trim();
|
||||
const sessionId = String(env.ECC_SESSION_ID || env.CLAUDE_SESSION_ID || '').trim();
|
||||
if (!toolName || !sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolInput = input?.tool_input || {};
|
||||
const fileEvents = extractFileEvents(toolName, toolInput).map(event =>
|
||||
enrichFileEventFromWorkingTree(toolName, event)
|
||||
);
|
||||
const filePaths = fileEvents.length > 0
|
||||
? [...new Set(fileEvents.map(event => event.path))]
|
||||
: extractFilePaths(toolInput);
|
||||
|
||||
return {
|
||||
id: `tool-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
session_id: sessionId,
|
||||
tool_name: toolName,
|
||||
input_summary: summarizeInput(toolName, toolInput, filePaths),
|
||||
input_params_json: sanitizeInputParams(toolInput),
|
||||
output_summary: summarizeOutput(input?.tool_output),
|
||||
duration_ms: 0,
|
||||
file_paths: filePaths,
|
||||
file_events: fileEvents,
|
||||
};
|
||||
}
|
||||
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = rawInput.trim() ? JSON.parse(rawInput) : {};
|
||||
const row = buildActivityRow(input);
|
||||
if (row) {
|
||||
appendFile(
|
||||
path.join(getClaudeDir(), 'metrics', METRICS_FILE_NAME),
|
||||
`${JSON.stringify(row)}\n`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Keep hook non-blocking.
|
||||
}
|
||||
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
function main() {
|
||||
let raw = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
process.stdout.write(run(raw));
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildActivityRow,
|
||||
extractFileEvents,
|
||||
extractFilePaths,
|
||||
summarizeInput,
|
||||
summarizeOutput,
|
||||
run,
|
||||
};
|
||||
@@ -27,7 +27,7 @@ Usage: install.sh [--target <${LEGACY_INSTALL_TARGETS.join('|')}>] [--dry-run] [
|
||||
install.sh [--dry-run] [--json] --config <path>
|
||||
|
||||
Targets:
|
||||
claude (default) - Install rules to ~/.claude/rules/
|
||||
claude (default) - Install ECC into ~/.claude/ (hooks, commands, agents, rules, skills)
|
||||
cursor - Install rules, hooks, and bundled Cursor configs to ./.cursor/
|
||||
antigravity - Install rules, workflows, skills, and agents to ./.agent/
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Shared cost estimation for ECC hooks.
|
||||
*
|
||||
* Approximate per-1M-token blended rates (conservative defaults).
|
||||
*/
|
||||
|
||||
const RATE_TABLE = {
|
||||
haiku: { in: 0.8, out: 4.0 },
|
||||
sonnet: { in: 3.0, out: 15.0 },
|
||||
opus: { in: 15.0, out: 75.0 }
|
||||
};
|
||||
|
||||
/**
|
||||
* Estimate USD cost from token counts.
|
||||
* @param {string} model - Model name (may contain "haiku", "sonnet", or "opus")
|
||||
* @param {number} inputTokens
|
||||
* @param {number} outputTokens
|
||||
* @returns {number} Estimated cost in USD (rounded to 6 decimal places)
|
||||
*/
|
||||
function estimateCost(model, inputTokens, outputTokens) {
|
||||
const normalized = String(model || '').toLowerCase();
|
||||
let rates = RATE_TABLE.sonnet;
|
||||
if (normalized.includes('haiku')) rates = RATE_TABLE.haiku;
|
||||
if (normalized.includes('opus')) rates = RATE_TABLE.opus;
|
||||
|
||||
const cost = (inputTokens / 1_000_000) * rates.in + (outputTokens / 1_000_000) * rates.out;
|
||||
return Math.round(cost * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
module.exports = { estimateCost, RATE_TABLE };
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Runtime helpers for ecc_dashboard.py that do not depend on tkinter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from typing import Optional, Tuple, Dict, List
|
||||
|
||||
|
||||
def maximize_window(window) -> None:
|
||||
"""Maximize the dashboard window using the safest supported method."""
|
||||
try:
|
||||
window.state('zoomed')
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
system_name = platform.system()
|
||||
if system_name == 'Linux':
|
||||
try:
|
||||
window.attributes('-zoomed', True)
|
||||
except Exception:
|
||||
pass
|
||||
elif system_name == 'Darwin':
|
||||
try:
|
||||
window.attributes('-fullscreen', True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def build_terminal_launch(
|
||||
path: str,
|
||||
*,
|
||||
os_name: Optional[str] = None,
|
||||
system_name: Optional[str] = None,
|
||||
) -> Tuple[List[str], Dict[str, object]]:
|
||||
"""Return safe argv/kwargs for opening a terminal rooted at the requested path."""
|
||||
resolved_os_name = os_name or os.name
|
||||
resolved_system_name = system_name or platform.system()
|
||||
|
||||
if resolved_os_name == 'nt':
|
||||
creationflags = getattr(subprocess, 'CREATE_NEW_CONSOLE', 0)
|
||||
return (
|
||||
['cmd.exe', '/k', 'cd', '/d', path],
|
||||
{
|
||||
'cwd': path,
|
||||
'creationflags': creationflags,
|
||||
},
|
||||
)
|
||||
|
||||
if resolved_system_name == 'Darwin':
|
||||
return (['open', '-a', 'Terminal', path], {})
|
||||
|
||||
return (
|
||||
['x-terminal-emulator', '-e', 'bash', '-lc', 'cd -- "$1"; exec bash', 'bash', path],
|
||||
{},
|
||||
)
|
||||
@@ -37,6 +37,7 @@ const LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET = Object.freeze({
|
||||
],
|
||||
});
|
||||
const LEGACY_LANGUAGE_ALIAS_TO_CANONICAL = Object.freeze({
|
||||
c: 'c',
|
||||
cpp: 'cpp',
|
||||
csharp: 'csharp',
|
||||
go: 'go',
|
||||
@@ -52,6 +53,7 @@ const LEGACY_LANGUAGE_ALIAS_TO_CANONICAL = Object.freeze({
|
||||
typescript: 'typescript',
|
||||
});
|
||||
const LEGACY_LANGUAGE_EXTRA_MODULE_IDS = Object.freeze({
|
||||
c: ['framework-language'],
|
||||
cpp: ['framework-language'],
|
||||
csharp: ['framework-language'],
|
||||
go: ['framework-language'],
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
createFlatRuleOperations,
|
||||
createInstallTargetAdapter,
|
||||
createManagedOperation,
|
||||
isForeignPlatformPath,
|
||||
} = require('./helpers');
|
||||
|
||||
function toCursorRuleFileName(fileName, sourceRelativeFile) {
|
||||
if (path.basename(sourceRelativeFile).toLowerCase() === 'readme.md') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fileName.endsWith('.md')
|
||||
? `${fileName.slice(0, -3)}.mdc`
|
||||
: fileName;
|
||||
}
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'cursor-project',
|
||||
target: 'cursor',
|
||||
@@ -17,6 +29,7 @@ module.exports = createInstallTargetAdapter({
|
||||
const modules = Array.isArray(input.modules)
|
||||
? input.modules
|
||||
: (input.module ? [input.module] : []);
|
||||
const seenDestinationPaths = new Set();
|
||||
const {
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
@@ -28,23 +41,98 @@ module.exports = createInstallTargetAdapter({
|
||||
homeDir,
|
||||
};
|
||||
const targetRoot = adapter.resolveRoot(planningInput);
|
||||
|
||||
return modules.flatMap(module => {
|
||||
const entries = modules.flatMap((module, moduleIndex) => {
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
.filter(p => !isForeignPlatformPath(p, adapter.target))
|
||||
.flatMap(sourceRelativePath => {
|
||||
if (sourceRelativePath === 'rules') {
|
||||
return createFlatRuleOperations({
|
||||
moduleId: module.id,
|
||||
repoRoot,
|
||||
sourceRelativePath,
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
});
|
||||
}
|
||||
.map((sourceRelativePath, pathIndex) => ({
|
||||
module,
|
||||
sourceRelativePath,
|
||||
moduleIndex,
|
||||
pathIndex,
|
||||
}));
|
||||
}).sort((left, right) => {
|
||||
const getPriority = value => {
|
||||
if (value === '.cursor') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
|
||||
if (value === 'rules') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 2;
|
||||
};
|
||||
|
||||
const leftPriority = getPriority(left.sourceRelativePath);
|
||||
const rightPriority = getPriority(right.sourceRelativePath);
|
||||
if (leftPriority !== rightPriority) {
|
||||
return leftPriority - rightPriority;
|
||||
}
|
||||
|
||||
if (left.moduleIndex !== right.moduleIndex) {
|
||||
return left.moduleIndex - right.moduleIndex;
|
||||
}
|
||||
|
||||
return left.pathIndex - right.pathIndex;
|
||||
});
|
||||
|
||||
function takeUniqueOperations(operations) {
|
||||
return operations.filter(operation => {
|
||||
if (!operation || !operation.destinationPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (seenDestinationPaths.has(operation.destinationPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenDestinationPaths.add(operation.destinationPath);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return entries.flatMap(({ module, sourceRelativePath }) => {
|
||||
if (sourceRelativePath === 'rules') {
|
||||
return takeUniqueOperations(createFlatRuleOperations({
|
||||
moduleId: module.id,
|
||||
repoRoot,
|
||||
sourceRelativePath,
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
destinationNameTransform: toCursorRuleFileName,
|
||||
}));
|
||||
}
|
||||
|
||||
if (sourceRelativePath === '.cursor') {
|
||||
const cursorRoot = path.join(repoRoot, '.cursor');
|
||||
if (!fs.existsSync(cursorRoot) || !fs.statSync(cursorRoot).isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const childOperations = fs.readdirSync(cursorRoot, { withFileTypes: true })
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.filter(entry => entry.name !== 'rules')
|
||||
.map(entry => createManagedOperation({
|
||||
moduleId: module.id,
|
||||
sourceRelativePath: path.join('.cursor', entry.name),
|
||||
destinationPath: path.join(targetRoot, entry.name),
|
||||
strategy: 'preserve-relative-path',
|
||||
}));
|
||||
|
||||
const ruleOperations = createFlatRuleOperations({
|
||||
moduleId: module.id,
|
||||
repoRoot,
|
||||
sourceRelativePath: '.cursor/rules',
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
destinationNameTransform: toCursorRuleFileName,
|
||||
});
|
||||
|
||||
return takeUniqueOperations([...childOperations, ...ruleOperations]);
|
||||
}
|
||||
|
||||
return takeUniqueOperations([
|
||||
adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput),
|
||||
]);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -181,7 +181,13 @@ function createNamespacedFlatRuleOperations(adapter, moduleId, sourceRelativePat
|
||||
return operations;
|
||||
}
|
||||
|
||||
function createFlatRuleOperations({ moduleId, repoRoot, sourceRelativePath, destinationDir }) {
|
||||
function createFlatRuleOperations({
|
||||
moduleId,
|
||||
repoRoot,
|
||||
sourceRelativePath,
|
||||
destinationDir,
|
||||
destinationNameTransform,
|
||||
}) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
const sourceRoot = path.join(repoRoot || '', normalizedSourcePath);
|
||||
|
||||
@@ -201,19 +207,33 @@ function createFlatRuleOperations({ moduleId, repoRoot, sourceRelativePath, dest
|
||||
if (entry.isDirectory()) {
|
||||
const relativeFiles = listRelativeFiles(entryPath);
|
||||
for (const relativeFile of relativeFiles) {
|
||||
const flattenedFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`;
|
||||
const defaultFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`;
|
||||
const sourceRelativeFile = path.join(normalizedSourcePath, namespace, relativeFile);
|
||||
const flattenedFileName = typeof destinationNameTransform === 'function'
|
||||
? destinationNameTransform(defaultFileName, sourceRelativeFile)
|
||||
: defaultFileName;
|
||||
if (!flattenedFileName) {
|
||||
continue;
|
||||
}
|
||||
operations.push(createManagedOperation({
|
||||
moduleId,
|
||||
sourceRelativePath: path.join(normalizedSourcePath, namespace, relativeFile),
|
||||
sourceRelativePath: sourceRelativeFile,
|
||||
destinationPath: path.join(destinationDir, flattenedFileName),
|
||||
strategy: 'flatten-copy',
|
||||
}));
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
const sourceRelativeFile = path.join(normalizedSourcePath, entry.name);
|
||||
const destinationFileName = typeof destinationNameTransform === 'function'
|
||||
? destinationNameTransform(entry.name, sourceRelativeFile)
|
||||
: entry.name;
|
||||
if (!destinationFileName) {
|
||||
continue;
|
||||
}
|
||||
operations.push(createManagedOperation({
|
||||
moduleId,
|
||||
sourceRelativePath: path.join(normalizedSourcePath, entry.name),
|
||||
destinationPath: path.join(destinationDir, entry.name),
|
||||
sourceRelativePath: sourceRelativeFile,
|
||||
destinationPath: path.join(destinationDir, destinationFileName),
|
||||
strategy: 'flatten-copy',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -46,80 +46,6 @@ function replacePluginRootPlaceholders(value, pluginRoot) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildLegacyHookSignature(entry, pluginRoot) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedEntry = replacePluginRootPlaceholders(entry, pluginRoot);
|
||||
|
||||
if (typeof normalizedEntry.matcher !== 'string' || !Array.isArray(normalizedEntry.hooks)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hookSignature = normalizedEntry.hooks.map(hook => JSON.stringify({
|
||||
type: hook && typeof hook === 'object' ? hook.type : undefined,
|
||||
command: hook && typeof hook === 'object' ? hook.command : undefined,
|
||||
timeout: hook && typeof hook === 'object' ? hook.timeout : undefined,
|
||||
async: hook && typeof hook === 'object' ? hook.async : undefined,
|
||||
}));
|
||||
|
||||
return JSON.stringify({
|
||||
matcher: normalizedEntry.matcher,
|
||||
hooks: hookSignature,
|
||||
});
|
||||
}
|
||||
|
||||
function getHookEntryAliases(entry, pluginRoot) {
|
||||
const aliases = [];
|
||||
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return aliases;
|
||||
}
|
||||
|
||||
const normalizedEntry = replacePluginRootPlaceholders(entry, pluginRoot);
|
||||
|
||||
if (typeof normalizedEntry.id === 'string' && normalizedEntry.id.trim().length > 0) {
|
||||
aliases.push(`id:${normalizedEntry.id.trim()}`);
|
||||
}
|
||||
|
||||
const legacySignature = buildLegacyHookSignature(normalizedEntry, pluginRoot);
|
||||
if (legacySignature) {
|
||||
aliases.push(`legacy:${legacySignature}`);
|
||||
}
|
||||
|
||||
aliases.push(`json:${JSON.stringify(normalizedEntry)}`);
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
function mergeHookEntries(existingEntries, incomingEntries, pluginRoot) {
|
||||
const mergedEntries = [];
|
||||
const seenEntries = new Set();
|
||||
|
||||
for (const entry of [...existingEntries, ...incomingEntries]) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('id' in entry && typeof entry.id !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const aliases = getHookEntryAliases(entry, pluginRoot);
|
||||
if (aliases.some(alias => seenEntries.has(alias))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const alias of aliases) {
|
||||
seenEntries.add(alias);
|
||||
}
|
||||
mergedEntries.push(replacePluginRootPlaceholders(entry, pluginRoot));
|
||||
}
|
||||
|
||||
return mergedEntries;
|
||||
}
|
||||
|
||||
function findHooksSourcePath(plan, hooksDestinationPath) {
|
||||
const operation = plan.operations.find(item => item.destinationPath === hooksDestinationPath);
|
||||
return operation ? operation.sourcePath : null;
|
||||
@@ -168,7 +94,7 @@ function buildFilteredMcpWrites(plan) {
|
||||
return writes;
|
||||
}
|
||||
|
||||
function buildMergedSettings(plan) {
|
||||
function buildResolvedClaudeHooks(plan) {
|
||||
if (!plan.adapter || plan.adapter.target !== 'claude') {
|
||||
return null;
|
||||
}
|
||||
@@ -181,46 +107,22 @@ function buildMergedSettings(plan) {
|
||||
}
|
||||
|
||||
const hooksConfig = readJsonObject(hooksSourcePath, 'hooks config');
|
||||
const incomingHooks = replacePluginRootPlaceholders(hooksConfig.hooks, pluginRoot);
|
||||
if (!incomingHooks || typeof incomingHooks !== 'object' || Array.isArray(incomingHooks)) {
|
||||
const resolvedHooks = replacePluginRootPlaceholders(hooksConfig.hooks, pluginRoot);
|
||||
if (!resolvedHooks || typeof resolvedHooks !== 'object' || Array.isArray(resolvedHooks)) {
|
||||
throw new Error(`Invalid hooks config at ${hooksSourcePath}: expected "hooks" to be a JSON object`);
|
||||
}
|
||||
|
||||
const settingsPath = path.join(plan.targetRoot, 'settings.json');
|
||||
let settings = {};
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
settings = readJsonObject(settingsPath, 'existing settings');
|
||||
}
|
||||
|
||||
const existingHooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
|
||||
? settings.hooks
|
||||
: {};
|
||||
const mergedHooks = { ...existingHooks };
|
||||
|
||||
for (const [eventName, incomingEntries] of Object.entries(incomingHooks)) {
|
||||
const currentEntries = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
|
||||
const nextEntries = Array.isArray(incomingEntries) ? incomingEntries : [];
|
||||
mergedHooks[eventName] = mergeHookEntries(currentEntries, nextEntries, pluginRoot);
|
||||
}
|
||||
|
||||
const mergedSettings = {
|
||||
...settings,
|
||||
hooks: mergedHooks,
|
||||
};
|
||||
|
||||
return {
|
||||
settingsPath,
|
||||
mergedSettings,
|
||||
hooksDestinationPath,
|
||||
resolvedHooksConfig: {
|
||||
...hooksConfig,
|
||||
hooks: incomingHooks,
|
||||
hooks: resolvedHooks,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyInstallPlan(plan) {
|
||||
const mergedSettingsPlan = buildMergedSettings(plan);
|
||||
const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(plan);
|
||||
const filteredMcpWrites = buildFilteredMcpWrites(plan);
|
||||
|
||||
for (const operation of plan.operations) {
|
||||
@@ -228,17 +130,11 @@ function applyInstallPlan(plan) {
|
||||
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
|
||||
}
|
||||
|
||||
if (mergedSettingsPlan) {
|
||||
fs.mkdirSync(path.dirname(mergedSettingsPlan.hooksDestinationPath), { recursive: true });
|
||||
if (resolvedClaudeHooksPlan) {
|
||||
fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
mergedSettingsPlan.hooksDestinationPath,
|
||||
JSON.stringify(mergedSettingsPlan.resolvedHooksConfig, null, 2) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
fs.mkdirSync(path.dirname(mergedSettingsPlan.settingsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
mergedSettingsPlan.settingsPath,
|
||||
JSON.stringify(mergedSettingsPlan.mergedSettings, null, 2) + '\n',
|
||||
resolvedClaudeHooksPlan.hooksDestinationPath,
|
||||
JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ const LANGUAGE_RULES = [
|
||||
markers: ['pom.xml', 'build.gradle', 'build.gradle.kts'],
|
||||
extensions: ['.java']
|
||||
},
|
||||
{
|
||||
type: 'c',
|
||||
markers: [],
|
||||
extensions: ['.c']
|
||||
},
|
||||
{
|
||||
type: 'csharp',
|
||||
markers: [],
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Shared session bridge utilities for ECC hooks.
|
||||
*
|
||||
* The bridge file is a small JSON aggregate in /tmp that allows
|
||||
* statusline, metrics-bridge, and context-monitor to share state
|
||||
* without scanning large JSONL logs on every invocation.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const MAX_SESSION_ID_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* Sanitize a session ID for safe use in file paths.
|
||||
* Rejects path traversal, strips unsafe chars, limits length.
|
||||
* @param {string} raw
|
||||
* @returns {string|null} Safe session ID or null if invalid
|
||||
*/
|
||||
function sanitizeSessionId(raw) {
|
||||
if (!raw || typeof raw !== 'string') return null;
|
||||
if (/[/\\]|\.\./.test(raw)) return null;
|
||||
const safe = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, MAX_SESSION_ID_LENGTH);
|
||||
return safe || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bridge file path for a session.
|
||||
* @param {string} sessionId - Already-sanitized session ID
|
||||
* @returns {string}
|
||||
*/
|
||||
function getBridgePath(sessionId) {
|
||||
return path.join(os.tmpdir(), `ecc-metrics-${sessionId}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read bridge data. Returns null on any error.
|
||||
* @param {string} sessionId - Already-sanitized session ID
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function readBridge(sessionId) {
|
||||
try {
|
||||
const raw = fs.readFileSync(getBridgePath(sessionId), 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write bridge data atomically (write .tmp then rename).
|
||||
* @param {string} sessionId - Already-sanitized session ID
|
||||
* @param {object} data
|
||||
*/
|
||||
function writeBridgeAtomic(sessionId, data) {
|
||||
const target = getBridgePath(sessionId);
|
||||
const tmp = `${target}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
|
||||
fs.renameSync(tmp, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve session ID from environment variables.
|
||||
* @returns {string|null} Sanitized session ID or null
|
||||
*/
|
||||
function resolveSessionId() {
|
||||
const raw = process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || '';
|
||||
return sanitizeSessionId(raw);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sanitizeSessionId,
|
||||
getBridgePath,
|
||||
readBridge,
|
||||
writeBridgeAtomic,
|
||||
resolveSessionId,
|
||||
MAX_SESSION_ID_LENGTH
|
||||
};
|
||||
+192
-11
@@ -6,9 +6,22 @@ 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"
|
||||
OPENCODE_ECC_HOOKS_PLUGIN=".opencode/plugins/ecc-hooks.ts"
|
||||
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 +49,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" "$OPENCODE_ECC_HOOKS_PLUGIN" "$README_FILE" "$ZH_CN_README_FILE" "$SELECTIVE_INSTALL_ARCHITECTURE_DOC"; do
|
||||
if [[ ! -f "$FILE" ]]; then
|
||||
echo "Error: $FILE not found"
|
||||
exit 1
|
||||
@@ -58,13 +71,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 +81,189 @@ 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_opencode_hook_banner_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(
|
||||
/(## Active Plugin: Everything Claude Code v)[0-9]+\.[0-9]+\.[0-9]+/,
|
||||
`$1${version}`
|
||||
);
|
||||
if (updated === current) {
|
||||
console.error(`Error: could not update OpenCode hook banner version in ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
fs.writeFileSync(file, updated);
|
||||
' "$OPENCODE_ECC_HOOKS_PLUGIN" "$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_opencode_hook_banner_version
|
||||
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" "$OPENCODE_ECC_HOOKS_PLUGIN" "$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"
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: accessibility
|
||||
description: Design, implement, and audit inclusive digital products using WCAG 2.2 Level AA
|
||||
standards. Use this skill to generate semantic ARIA for Web and accessibility traits for Web and Native platforms (iOS/Android).
|
||||
origin: ECC
|
||||
---
|
||||
|
||||
# Accessibility (WCAG 2.2)
|
||||
|
||||
This skill ensures that digital interfaces are Perceivable, Operable, Understandable, and Robust (POUR) for all users, including those using screen readers, switch controls, or keyboard navigation. It focuses on the technical implementation of WCAG 2.2 success criteria.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Defining UI component specifications for Web, iOS, or Android.
|
||||
- Auditing existing code for accessibility barriers or compliance gaps.
|
||||
- Implementing new WCAG 2.2 standards like Target Size (Minimum) and Focus Appearance.
|
||||
- Mapping high-level design requirements to technical attributes (ARIA roles, traits, hints).
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- **POUR Principles**: The foundation of WCAG (Perceivable, Operable, Understandable, Robust).
|
||||
- **Semantic Mapping**: Using native elements over generic containers to provide built-in accessibility.
|
||||
- **Accessibility Tree**: The representation of the UI that assistive technologies actually "read."
|
||||
- **Focus Management**: Controlling the order and visibility of the keyboard/screen reader cursor.
|
||||
- **Labeling & Hints**: Providing context through `aria-label`, `accessibilityLabel`, and `contentDescription`.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Step 1: Identify the Component Role
|
||||
|
||||
Determine the functional purpose (e.g., Is this a button, a link, or a tab?). Use the most semantic native element available before resorting to custom roles.
|
||||
|
||||
### Step 2: Define Perceivable Attributes
|
||||
|
||||
- Ensure text contrast meets **4.5:1** (normal) or **3:1** (large/UI).
|
||||
- Add text alternatives for non-text content (images, icons).
|
||||
- Implement responsive reflow (up to 400% zoom without loss of function).
|
||||
|
||||
### Step 3: Implement Operable Controls
|
||||
|
||||
- Ensure a minimum **24x24 CSS pixel** target size (WCAG 2.2 SC 2.5.8).
|
||||
- Verify all interactive elements are reachable via keyboard and have a visible focus indicator (SC 2.4.11).
|
||||
- Provide single-pointer alternatives for dragging movements.
|
||||
|
||||
### Step 4: Ensure Understandable Logic
|
||||
|
||||
- Use consistent navigation patterns.
|
||||
- Provide descriptive error messages and suggestions for correction (SC 3.3.3).
|
||||
- Implement "Redundant Entry" (SC 3.3.7) to prevent asking for the same data twice.
|
||||
|
||||
### Step 5: Verify Robust Compatibility
|
||||
|
||||
- Use correct `Name, Role, Value` patterns.
|
||||
- Implement `aria-live` or live regions for dynamic status updates.
|
||||
|
||||
## Accessibility Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
UI["UI Component"] --> Platform{Platform?}
|
||||
Platform -->|Web| ARIA["WAI-ARIA + HTML5"]
|
||||
Platform -->|iOS| SwiftUI["Accessibility Traits + Labels"]
|
||||
Platform -->|Android| Compose["Semantics + ContentDesc"]
|
||||
|
||||
ARIA --> AT["Assistive Technology (Screen Readers, Switches)"]
|
||||
SwiftUI --> AT
|
||||
Compose --> AT
|
||||
```
|
||||
|
||||
## Cross-Platform Mapping
|
||||
|
||||
| Feature | Web (HTML/ARIA) | iOS (SwiftUI) | Android (Compose) |
|
||||
| :----------------- | :----------------------- | :----------------------------------- | :---------------------------------------------------------- |
|
||||
| **Primary Label** | `aria-label` / `<label>` | `.accessibilityLabel()` | `contentDescription` |
|
||||
| **Secondary Hint** | `aria-describedby` | `.accessibilityHint()` | `Modifier.semantics { stateDescription = ... }` |
|
||||
| **Action Role** | `role="button"` | `.accessibilityAddTraits(.isButton)` | `Modifier.semantics { role = Role.Button }` |
|
||||
| **Live Updates** | `aria-live="polite"` | `.accessibilityLiveRegion(.polite)` | `Modifier.semantics { liveRegion = LiveRegionMode.Polite }` |
|
||||
|
||||
## Examples
|
||||
|
||||
### Web: Accessible Search
|
||||
|
||||
```html
|
||||
<form role="search">
|
||||
<label for="search-input" class="sr-only">Search products</label>
|
||||
<input type="search" id="search-input" placeholder="Search..." />
|
||||
<button type="submit" aria-label="Submit Search">
|
||||
<svg aria-hidden="true">...</svg>
|
||||
</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### iOS: Accessible Action Button
|
||||
|
||||
```swift
|
||||
Button(action: deleteItem) {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.accessibilityLabel("Delete item")
|
||||
.accessibilityHint("Permanently removes this item from your list")
|
||||
.accessibilityAddTraits(.isButton)
|
||||
```
|
||||
|
||||
### Android: Accessible Toggle
|
||||
|
||||
```kotlin
|
||||
Switch(
|
||||
checked = isEnabled,
|
||||
onCheckedChange = { onToggle() },
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = "Enable notifications"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
- **Div-Buttons**: Using a `<div>` or `<span>` for a click event without adding a role and keyboard support.
|
||||
- **Color-Only Meaning**: Indicating an error or status _only_ with a color change (e.g., turning a border red).
|
||||
- **Uncontained Modal Focus**: Modals that don't trap focus, allowing keyboard users to navigate background content while the modal is open. Focus must be contained _and_ escapable via the `Escape` key or an explicit close button (WCAG SC 2.1.2).
|
||||
- **Redundant Alt Text**: Using "Image of..." or "Picture of..." in alt text (screen readers already announce the role "Image").
|
||||
|
||||
## Best Practices Checklist
|
||||
|
||||
- [ ] Interactive elements meet the **24x24px** (Web) or **44x44pt** (Native) target size.
|
||||
- [ ] Focus indicators are clearly visible and high-contrast.
|
||||
- [ ] Modals **contain focus** while open, and release it cleanly on close (`Escape` key or close button).
|
||||
- [ ] Dropdowns and menus restore focus to the trigger element on close.
|
||||
- [ ] Forms provide text-based error suggestions.
|
||||
- [ ] All icon-only buttons have a descriptive text label.
|
||||
- [ ] Content reflows properly when text is scaled.
|
||||
|
||||
## References
|
||||
|
||||
- [WCAG 2.2 Guidelines](https://www.w3.org/TR/WCAG22/)
|
||||
- [WAI-ARIA Authoring Practices](https://www.w3.org/TR/wai-aria-practices/)
|
||||
- [iOS Accessibility Programming Guide](https://developer.apple.com/documentation/accessibility)
|
||||
- [iOS Human Interface Guidelines - Accessibility](https://developer.apple.com/design/human-interface-guidelines/accessibility)
|
||||
- [Android Accessibility Developer Guide](https://developer.android.com/guide/topics/ui/accessibility)
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `frontend-patterns`
|
||||
- `frontend-design`
|
||||
- `liquid-glass-design`
|
||||
- `swiftui-patterns`
|
||||
@@ -118,4 +118,3 @@ src/integrations/
|
||||
- `backend-patterns`
|
||||
- `mcp-server-patterns`
|
||||
- `github-ops`
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ An interactive, step-by-step installation wizard for the Everything Claude Code
|
||||
## Prerequisites
|
||||
|
||||
This skill must be accessible to Claude Code before activation. Two ways to bootstrap:
|
||||
1. **Via Plugin**: `/plugin install ecc@ecc` — the plugin loads this skill automatically
|
||||
1. **Via Plugin**: `/plugin install everything-claude-code` — the plugin loads this skill automatically
|
||||
2. **Manual**: Copy only this skill to `~/.claude/skills/configure-ecc/SKILL.md`, then activate by saying "configure ecc"
|
||||
|
||||
---
|
||||
@@ -139,7 +139,7 @@ For each selected category, print the full list of skills below and ask the user
|
||||
|
||||
| Skill | Description |
|
||||
|-------|-------------|
|
||||
| `continuous-learning` | Auto-extract reusable patterns from sessions as learned skills |
|
||||
| `continuous-learning` | Legacy v1 Stop-hook session pattern extraction; prefer `continuous-learning-v2` for new installs |
|
||||
| `continuous-learning-v2` | Instinct-based learning with confidence scoring, evolves into skills, agents, and optional legacy command shims |
|
||||
| `eval-harness` | Formal evaluation framework for eval-driven development (EDD) |
|
||||
| `iterative-retrieval` | Progressive context refinement for subagent context problem |
|
||||
|
||||
@@ -106,4 +106,3 @@ Every panel should answer a real question. If it does not, remove it.
|
||||
- `research-ops`
|
||||
- `backend-patterns`
|
||||
- `terminal-ops`
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user