Compare commits

..

123 Commits

Author SHA1 Message Date
ulinzeng
9f9467f826 chore: register new hooks in hooks.json and update statusline example
Add post:ecc-metrics-bridge and post:ecc-context-monitor to PostToolUse
hooks. Update examples/statusline.json with ECC-native statusline config.
2026-04-20 15:15:42 +08:00
ulinzeng
cf79534c2c feat: add ecc-context-monitor with multi-dimensional warnings
PostToolUse hook injecting agent-facing warnings for:
- Context exhaustion (35% WARNING, 25% CRITICAL)
- Session cost ($5 NOTICE, $10 WARNING, $50 CRITICAL)
- Scope creep (>20 files modified)
- Tool loops (same tool+params 3x in last 5 calls)

Includes debounce (5 calls between warnings) with severity escalation bypass.
2026-04-20 15:15:35 +08:00
ulinzeng
aec611a98b feat: add ecc-statusline hook with metrics display
statusLine command showing model, current task, session cost, tool count,
files modified, session duration, and context usage bar with color
thresholds (green/yellow/orange/red).
2026-04-20 15:15:27 +08:00
ulinzeng
0f0efd7d7c feat: add ecc-metrics-bridge PostToolUse hook
Maintains a running session aggregate in /tmp/ecc-metrics-{session}.json
with cost, tool count, files modified, and recent tool ring buffer for
loop detection. Bridge file is read by ecc-statusline and ecc-context-monitor.
2026-04-20 15:15:20 +08:00
ulinzeng
b5294fc89a refactor: extract estimateCost from cost-tracker to shared lib
Replace inline estimateCost() in cost-tracker.js with import from
scripts/lib/cost-estimate.js. No behavior change.
2026-04-20 15:15:13 +08:00
ulinzeng
f579dad768 feat: add cost-estimate and session-bridge shared libs
Extract estimateCost() into scripts/lib/cost-estimate.js for reuse
across cost-tracker and ecc-metrics-bridge hooks.

Add scripts/lib/session-bridge.js with atomic bridge file I/O,
session ID sanitization, and path traversal prevention.
2026-04-20 15:15:07 +08:00
Affaan Mustafa
1a50145d39 Merge pull request #1462 from affaan-m/fix/remove-legacy-ecc-install-refs
fix: restore string hook commands for Claude Code schema
2026-04-15 20:07:04 -07:00
Affaan Mustafa
eb900ddd81 test: align hook install expectations with Claude schema 2026-04-15 19:33:34 -07:00
Affaan Mustafa
ccecb0b9f4 fix: restore string hook commands for Claude Code schema 2026-04-15 17:25:33 -07:00
Affaan Mustafa
9fb88c6700 Merge pull request #1449 from affaan-m/fix/bash-hook-dispatcher-forkstorm-clean
fix: consolidate bash hooks without fork storms
2026-04-15 17:23:09 -07:00
Affaan Mustafa
6b7bd7156c fix: relax pnpm strict build checks in CI 2026-04-15 16:44:58 -07:00
Affaan Mustafa
1fabf4d2cf fix: consolidate bash hooks without fork storms 2026-04-14 21:23:57 -07:00
Affaan Mustafa
7eb7c598fb Merge pull request #1448 from affaan-m/fix/manual-release-dispatch
fix: allow manual release workflow dispatch
2026-04-14 21:20:10 -07:00
Affaan Mustafa
8b5c0c1b07 fix: allow manual release workflow dispatch 2026-04-14 21:02:23 -07:00
Affaan Mustafa
c1e7a272cc Merge pull request #1446 from affaan-m/fix/release-publish-and-migration-docs
fix: publish npm releases and clarify install identifiers
2026-04-14 20:58:20 -07:00
Affaan Mustafa
5427c27930 Merge pull request #1445 from affaan-m/fix/plugin-installed-hook-root-resolution
fix: resolve plugin-installed hook root on marketplace installs
2026-04-14 20:43:40 -07:00
Affaan Mustafa
b5c4d2beb9 fix: wire npm auth into release publish 2026-04-14 20:43:22 -07:00
Affaan Mustafa
34380326c8 fix: publish npm releases and clarify install identifiers 2026-04-14 20:42:28 -07:00
Affaan Mustafa
9227d3cc30 docs: add ecc recovery guidance for wiped setups 2026-04-14 20:41:18 -07:00
Affaan Mustafa
8da668f1ac Merge pull request #1439 from affaan-m/fix/urgent-install-and-name
fix: unblock urgent install and gateguard regressions
2026-04-14 20:36:06 -07:00
Affaan Mustafa
1b7c5789fc fix: bootstrap plugin-installed hook commands safely 2026-04-14 20:24:21 -07:00
Affaan Mustafa
cdeb837838 Merge origin/main into fix/urgent-install-and-name 2026-04-14 20:23:54 -07:00
Affaan Mustafa
cca163c776 Merge pull request #1440 from affaan-m/fix/dashboard-terminal-safety
fix(dashboard): harden terminal launch and maximize behavior
2026-04-14 20:21:51 -07:00
Affaan Mustafa
c54b44edf3 test: fix harness audit env fallback 2026-04-14 20:03:57 -07:00
Affaan Mustafa
2691cfc0f1 fix: restore dashboard branch ci baseline 2026-04-14 19:54:28 -07:00
Affaan Mustafa
b2c4b7f51c Merge remote-tracking branch 'origin/main' into fix/urgent-install-and-name 2026-04-14 19:50:35 -07:00
Affaan Mustafa
c924290b5b fix: restore dashboard branch CI baseline 2026-04-14 19:46:00 -07:00
Affaan Mustafa
e46deb93c8 fix: harden dashboard terminal launch helpers 2026-04-14 19:44:32 -07:00
Affaan Mustafa
8776c4f8f3 fix: harden urgent install and gateguard patch 2026-04-14 19:44:08 -07:00
Affaan Mustafa
e5225db006 docs: sync catalog counts on urgent fix branch 2026-04-14 19:31:23 -07:00
Affaan Mustafa
48a30b53c8 Merge pull request #1402 from affaan-m/docs/community-skill-highlights
docs: add community skill ecosystem notes
2026-04-14 19:28:57 -07:00
Affaan Mustafa
3be24a5704 fix: restore urgent PR CI health 2026-04-14 19:26:24 -07:00
Affaan Mustafa
76b6e22b4d fix: unblock urgent install and gateguard regressions 2026-04-14 19:23:07 -07:00
Affaan Mustafa
ecc5e0e2d6 Merge pull request #1432 from S1lverline/fix/harness-audit-marketplaces
fix(harness-audit): detect ECC plugin under marketplaces/ subdirectory
2026-04-14 19:13:24 -07:00
S1lverline
aa96279ecc fix(harness-audit): detect ECC plugin under marketplaces/ subdirectory
`findPluginInstall()` in `scripts/harness-audit.js` scans two candidate
roots:

  {rootDir}/.claude/plugins/
  {HOME}/.claude/plugins/

Current Claude Code marketplace installs live one directory deeper:

  {HOME}/.claude/plugins/marketplaces/{ecc,everything-claude-code}/...

As a result, running `node scripts/harness-audit.js repo` on any
consumer project reports `consumer-plugin-install: false` even when ECC
is fully installed via marketplace, costing 4 points from Tool Coverage.

Add the `marketplaces/` intermediate directory to `candidateRoots` so
both legacy and current install layouts are recognized. The change is
purely additive: existing candidate paths still resolve, and the new
ones only match when the marketplace layout is present.

Reproduction:
  1. Install ECC via Claude Code plugin marketplace
  2. cd into any consumer project
  3. node ~/.claude/plugins/marketplaces/everything-claude-code/scripts/harness-audit.js repo
  4. Observe consumer-plugin-install=false despite a working install
2026-04-14 23:37:10 +09:00
Affaan Mustafa
e0ddb331f6 Merge pull request #1367 from ozoz5/feat/gateguard
feat(hooks,skills): add gateguard fact-forcing pre-action gate
2026-04-13 01:05:20 -07:00
Affaan Mustafa
85e331e49a Merge pull request #1369 from affaan-m/dependabot/github_actions/pnpm/action-setup-6.0.0
build(deps): bump pnpm/action-setup from 5.0.0 to 6.0.0
2026-04-13 01:05:16 -07:00
Affaan Mustafa
5eedc8adb4 Merge pull request #1377 from Anish29801/feat/dashboard-gui
Feat/dashboard gui
2026-04-13 01:04:14 -07:00
Affaan Mustafa
c64cc69eb2 Merge pull request #1363 from gnpthbalaji/feat/accessibility
feat(agent + skill): a11y-architect agent and accessibility skill
2026-04-13 00:59:57 -07:00
Affaan Mustafa
6c67566767 fix: keep gateguard session state alive 2026-04-13 00:58:50 -07:00
Affaan Mustafa
deb3b1dc14 fix: make dashboard GUI build surfaces opt-in safe 2026-04-13 00:56:56 -07:00
Affaan Mustafa
2e44beabc1 test: isolate gateguard state dir cleanup 2026-04-13 00:53:57 -07:00
Affaan Mustafa
e2b5353fec Merge pull request #1398 from affaan-m/fix/opencode-plugin-version-sync
fix: sync OpenCode hook banner version
2026-04-13 00:52:40 -07:00
Affaan Mustafa
9ae51bc3c1 Merge pull request #1393 from affaan-m/fix/cursor-rule-mdc-install
fix: install Cursor rules as .mdc files
2026-04-13 00:52:03 -07:00
Affaan Mustafa
7f7e2c2c52 fix: remove duplicate tools frontmatter key 2026-04-13 00:51:40 -07:00
Affaan Mustafa
7a33b2b3c9 Merge pull request #1395 from affaan-m/fix/npm-publish-surface
fix: narrow npm publish surface to the module graph
2026-04-13 00:46:15 -07:00
Affaan Mustafa
68ee51f1e3 docs: add community skill ecosystem notes 2026-04-13 00:45:51 -07:00
seto
dd2962ee92 fix: 5 bugs + 2 tests from 3-agent deep bughunt
Bugs fixed:
- B1: JS gate messages still said "cat one real record" -> redacted/synthetic
- B2: Destructive bash key used 200-char truncation (collision bypass) -> SHA256 hash
- B3: sanitizePath only stripped \n\r -> now strips null bytes, bidi overrides, all control chars
- B4: Tool name matching was case-sensitive (latent bypass) -> lookup map normalization
- B5: SKILL.md Gate Types missing MultiEdit -> added with explanation

Tests added:
- T1: MultiEdit gate denies first unchecked file (CRITICAL - was untested)
- T2: MultiEdit allows after all files gated

11/11 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:32:46 +09:00
Affaan Mustafa
5c4570baa5 Merge pull request #1370 from affaan-m/dependabot/github_actions/softprops/action-gh-release-3.0.0
build(deps): bump softprops/action-gh-release from 2.6.1 to 3.0.0
2026-04-13 00:30:59 -07:00
Affaan Mustafa
1a950e4f83 fix: allow pnpm cache probe under node 18 2026-04-13 00:21:42 -07:00
seto
8cd6378c81 fix: cubic-dev-ai round 3 — SKILL.md consistency
P2: Description now says "Edit/Write/Bash (including MultiEdit)"
    instead of listing MultiEdit as a separate top-level gate

P2: Write Gate and Anti-Patterns now use same "redacted or synthetic
    values" wording as Edit Gate (was still "cat one real record")

All 3 gate doc sections now consistent. 9/9 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:19:01 +09:00
Affaan Mustafa
ef7613c526 fix: use corepack pnpm on node 18 2026-04-13 00:17:17 -07:00
Affaan Mustafa
a0a1eda8fc fix: sync opencode hook banner version 2026-04-13 00:15:55 -07:00
Affaan Mustafa
bd207aabe1 fix: use pnpm 9 for node 18 workflow jobs 2026-04-13 00:13:54 -07:00
seto
4dbed5ff5b fix: cubic-dev-ai round 2 — 3 issues across SKILL.md + pruning
P1: Gate message asked for raw production data records — changed to
    "redacted or synthetic values" to prevent sensitive data exfiltration

P2: SKILL.md description now includes MultiEdit (was missing after
    MultiEdit gate was added in previous commit)

P2: Session key pruning now caps __prefixed keys at 50 to prevent
    unbounded growth even in theoretical edge cases

9/9 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:11:33 +09:00
Affaan Mustafa
6eadf786f5 fix: pin pnpm version for setup action v6 2026-04-13 00:10:39 -07:00
Affaan Mustafa
9e607ebb30 fix: prefer cursor native hooks during install 2026-04-13 00:07:15 -07:00
Affaan Mustafa
30f6ae4253 test: align cursor manifest expectations 2026-04-12 23:58:59 -07:00
Affaan Mustafa
c826305060 fix: keep runtime schemas in npm package 2026-04-12 23:56:58 -07:00
Affaan Mustafa
db8247d701 chore: update release action version comments 2026-04-12 23:54:26 -07:00
Affaan Mustafa
adb46a95a6 chore: update pnpm action version comments 2026-04-12 23:53:57 -07:00
Affaan Mustafa
48e5a1fa75 Merge pull request #1371 from affaan-m/dependabot/github_actions/actions/github-script-9.0.0
build(deps): bump actions/github-script from 8.0.0 to 9.0.0
2026-04-12 23:53:17 -07:00
Affaan Mustafa
2fb041c6de Merge pull request #1368 from affaan-m/dependabot/github_actions/actions/upload-artifact-7.0.1
build(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1
2026-04-12 23:53:01 -07:00
Affaan Mustafa
7374ef6a73 fix: normalize cursor rule installs 2026-04-12 23:51:58 -07:00
Affaan Mustafa
bd2aec48ed fix: narrow npm publish surface to the module graph 2026-04-12 23:48:53 -07:00
Affaan Mustafa
6dc6b9266a Merge pull request #1394 from affaan-m/fix/grader-after-step-classified
fix(grader): handle forward after_step references
2026-04-12 23:47:25 -07:00
seto
5540282dcb fix: remove unnecessary disk I/O + fix test cleanup
- isChecked() no longer calls saveState() — read-only operation
  should not write to disk (was causing 3x writes per tool call)
- Test cleanup uses fs.rmSync(recursive) instead of fs.rmdirSync
  which failed with ENOTEMPTY when .tmp files remained

9/9 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:41:58 +09:00
seto
67256194a0 fix: P1 test state-file PID mismatch + P2 session key eviction
P1 (cubic-dev-ai): Test process PID differs from spawned hook PID,
so test was seeding/clearing wrong state file. Fix: pass fixed
CLAUDE_SESSION_ID='gateguard-test-session' to spawned hooks.

P2 (cubic-dev-ai): Pruning checked array could evict __bash_session__
and other session keys, causing gates to re-fire mid-session. Fix:
preserve __prefixed keys during pruning, only evict file-path entries.

9/9 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:40:13 +09:00
Affaan Mustafa
5b0e123c10 Merge pull request #1392 from affaan-m/fix/hook-failed-to-load
fix: document supported Claude hook install path
2026-04-12 23:39:33 -07:00
Affaan Mustafa
bb96fdc9dc test: wait for http mcp fixtures to accept connections 2026-04-12 23:38:46 -07:00
seto
6ed1c643e7 fix: MultiEdit gate bypass — handle edits[].file_path correctly
P1 bug reported by greptile-apps: MultiEdit uses toolInput.edits[].file_path,
not toolInput.file_path. The gate was silently allowing all MultiEdit calls.

Fix: separate MultiEdit into its own branch that iterates edits array
and gates on the first unchecked file_path.

9/9 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:37:39 +09:00
Affaan Mustafa
0fcb43ea90 fix(grader): handle forward after_step references 2026-04-12 23:36:16 -07:00
Affaan Mustafa
133e881ce0 fix: install Cursor rules as mdc files 2026-04-12 23:32:39 -07:00
seto
45823fcede fix: session-scoped state to prevent cross-session race
Addresses reviewer feedback from @affaan-m:

1. State keyed by CLAUDE_SESSION_ID / ECC_SESSION_ID
   - Falls back to pid-based isolation when env vars absent
   - State file: state-{sessionId}.json (was .session_state.json)

2. Atomic write+rename semantics
   - Write to temp file, then fs.renameSync to final path
   - Prevents partial reads from concurrent hooks

3. Bounded checked list (MAX_CHECKED_ENTRIES = 500)
   - Prunes to last 500 entries when cap exceeded
   - Stale session files auto-deleted after 1 hour

9/9 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:30:34 +09:00
Affaan Mustafa
18c90a7a17 fix: document supported claude hook install path 2026-04-12 23:29:45 -07:00
Affaan Mustafa
9da8e5f6ac Merge pull request #1391 from affaan-m/fix/workflow-run-fork-security
fix: block unsafe privileged workflow checkouts
2026-04-12 23:23:56 -07:00
Affaan Mustafa
3792b69a38 fix: block unsafe privileged workflow checkouts 2026-04-12 23:23:01 -07:00
Affaan Mustafa
a2ad68e7e6 Merge pull request #1390 from affaan-m/fix/slash-command-plugin-root
fix: use shared slash-command plugin root resolver
2026-04-12 23:16:14 -07:00
Affaan Mustafa
1b17c5c9d8 test: match published claude plugin name 2026-04-12 23:14:38 -07:00
Affaan Mustafa
94e8f29d19 fix: use shared slash-command plugin root resolver 2026-04-12 23:10:29 -07:00
Affaan Mustafa
de8a7dfef8 Merge pull request #1383 from YASoftwareDev/fix/plugin-name-ecc-to-everything-claude-code
fix: rename plugin id from ecc to everything-claude-code in manifests
2026-04-12 23:03:54 -07:00
Affaan Mustafa
2b09308224 Merge pull request #1384 from KeWang0622/fix/lint-md028-eqeqeq
fix: resolve markdownlint MD028 + ESLint eqeqeq lint failures
2026-04-12 23:03:19 -07:00
Affaan Mustafa
5f55484fa9 Merge pull request #1385 from KeWang0622/fix/block-no-verify-hook
fix: route block-no-verify hook through run-with-flags.js
2026-04-12 23:02:19 -07:00
Affaan Mustafa
e29da39eaf Merge pull request #1389 from affaan-m/fix/hook-plugin-root-resolution
fix: stop duplicating managed Claude hooks into settings
2026-04-12 23:00:04 -07:00
Affaan Mustafa
f4c7aac5b8 fix: remove unused hook install test constant 2026-04-12 22:51:03 -07:00
Affaan Mustafa
b749f5d772 fix: clean up hook install docs and tests 2026-04-12 22:47:25 -07:00
Affaan Mustafa
2ece2cfc90 fix: stop injecting managed hooks into claude settings 2026-04-12 22:39:48 -07:00
Affaan Mustafa
28edd197c2 fix: harden release surface version and packaging sync (#1388)
* fix: keep ecc release surfaces version-synced

* fix: keep lockfile release version in sync

* fix: remove release version drift from locks and tests

* fix: keep root release metadata version-synced

* fix: keep codex marketplace metadata version-synced

* fix: gate release workflows on full metadata sync

* fix: ship all versioned release metadata

* fix: harden manual release path

* fix: keep localized release docs version-synced

* fix: sync install architecture version examples

* test: cover shipped plugin metadata in npm pack

* fix: verify final npm payload in release script

* fix: ship opencode lockfile in npm package

* docs: sync localized release highlights

* fix: stabilize windows ci portability

* fix: tighten release script version sync

* fix: prefer repo-relative hook file paths

* fix: make npm pack test shell-safe on windows
2026-04-12 22:33:32 -07:00
Affaan Mustafa
fc5921a521 fix: detach ecc2 background session runners (#1387)
* fix: detach ecc2 background session runners

* fix: stabilize windows ci portability

* fix: persist detached runner startup stderr

* fix: prefer repo-relative hook file paths

* fix: make npm pack test shell-safe on windows
2026-04-12 22:29:05 -07:00
Ke Wang
809e0fa0a9 fix: address PR review comments on block-no-verify hook
- Add `minimal` profile so the security hook runs in all profiles
- Scope -n/--no-verify flag check to the detected subcommand region,
  preventing false positives on chained commands (e.g. `git log -n 10`)
- Guard stdin listeners with `require.main === module` so require()
  from run-with-flags.js does not register unnecessary listeners
- Verify subcommand token is preceded only by flags/flag-args after
  "git", preventing misclassification of argument values as subcommands
- Add integration tests for block-no-verify hook

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:29:01 -05:00
Ke Wang
dae663d856 fix: route block-no-verify hook through run-with-flags.js
Replace inline `npx block-no-verify@1.1.2` with a standalone Node.js
script routed through `run-with-flags.js`, matching every other hook.

Fixes two bugs:
1. npx inherits the project cwd and triggers EBADDEVENGINES in
   pnpm-only projects that set devEngines.packageManager.onFail=error.
2. The hook bypassed run-with-flags.js so ECC_DISABLED_HOOKS had no
   effect — the isHookEnabled() check never ran.

The new script replicates the full block-no-verify@1.1.2 detection
logic (--no-verify, -n shorthand for commit, core.hooksPath override)
with zero external dependencies.

Closes #1378
2026-04-12 19:53:15 -05:00
Ke Wang
6a247d4c43 fix: resolve markdownlint MD028 and ESLint eqeqeq warnings
Fix two lint issues that cause `npm run lint` to exit non-zero:

1. README.md (MD028): Two consecutive blockquotes separated by a bare
   blank line. Markdownlint treats this as one blockquote with an
   illegal blank line inside. Replace the blank line with a `>`
   continuation so both paragraphs stay in the same blockquote.

2. session-activity-tracker.js (eqeqeq): Three instances of `== null`
   replaced with explicit `=== null || === undefined` guards to satisfy
   the repo's `eqeqeq: warn` ESLint rule.

Closes #1366
2026-04-12 16:00:55 -05:00
Wojciech Pędzimąż
92e5b4d415 fix: rename plugin id from ecc to everything-claude-code in manifests
The marketplace is registered externally as `everything-claude-code`,
so the Claude Code CLI looks for a plugin named `everything-claude-code`
within it. Both `.claude-plugin/marketplace.json` and
`.claude-plugin/plugin.json` used the short alias `ecc` for the plugin
`name` field, causing a lookup miss at install/update time:

  Error: Plugin everything-claude-code not found in marketplace everything-claude-code

Change the `name` field in both files to match the external identifier.
2026-04-12 21:45:31 +02:00
seto
9a64e0d271 fix: gate MultiEdit tool alongside Edit/Write
MultiEdit was bypassing the fact-forcing gate because only Edit and
Write were checked. Now MultiEdit triggers the same edit gate (list
importers, public API, data schemas) before allowing file modifications.

Updated both the hook logic and hooks.json matcher pattern.

Addresses coderabbit/greptile/cubic-dev: "MultiEdit bypasses gate"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 18:18:16 +09:00
seto
b6a290d061 fix: allow destructive bash retry after facts presented
Destructive bash gate previously denied every invocation with no
isChecked call, creating an infinite deny loop. Now gates per-command
on first attempt and allows retry after the model presents the required
facts (targets, rollback plan, user instruction).

Addresses greptile P1: "Destructive bash gate permanently blocks"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 18:08:15 +09:00
seto
96139b2dad fix: address P2 review feedback (coderabbitai, cubic-dev-ai)
- GATEGUARD_STATE_DIR env var for test isolation (hook + tests)
- Exit code assertions on all 9 tests (no vacuous passes)
- Non-vacuous allow-path assertions (verify pass-through preserves input)
- Robust newline-injection assertion
- clearState() now reports errors instead of swallowing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 18:04:09 +09:00
seto
8a2d13187c fix: address P1 review feedback from greptile bot
1. Use run-with-flags.js wrapper (supports ECC_HOOK_PROFILE, ECC_DISABLED_HOOKS)
2. Add session timeout (30min inactivity = state reset, fixes "once ever" bug)
3. Add 9 integration tests (deny/allow/timeout/sanitize/disable)

Refactored hook to module.exports.run() pattern for direct require() by
run-with-flags.js (~50-100ms faster per invocation).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 17:42:32 +09:00
Anish
813755b879 Done 2026-04-12 12:56:20 +05:30
Anish
74b91cb3f3 fix: resolve git conflicts in LLM abstraction layer
- Fix gui() function import in __init__.py (use cli.selector)
- Fix prompt builder system message merging logic
- Add default max_tokens for Anthropic API in claude.py
- Fix openai tool_call arguments parsing with json.loads
- Fix test_builder.py PromptConfig import and assertions
2026-04-12 07:10:54 +00:00
Anish
d39a8a049a Small changes 2026-04-12 12:34:45 +05:30
Anish Agrawal
35aa02c645 Update pyproject.toml
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-12 12:31:22 +05:30
Anish Agrawal
fd0cde69d8 Update src/llm/__init__.py
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-12 12:31:03 +05:30
Anish Agrawal
0f6d06d779 Update pyproject.toml
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-12 12:26:12 +05:30
Anish Agrawal
c277b19ee8 Update tests/test_builder.py
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-12 12:25:56 +05:30
Anish Agrawal
5736b3b684 Update src/llm/prompt/builder.py
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-12 12:25:36 +05:30
Anish Agrawal
6691e7cc9e Update src/llm/providers/openai.py
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-12 12:25:15 +05:30
Anish Agrawal
a7f73576a9 Update src/llm/providers/claude.py
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-12 12:24:49 +05:30
Anish
7fc44c91b8 feat: add dashboard GUI with theme, font customization, and logo
- Add ecc_dashboard.py - Tkinter-based GUI for exploring ECC components
- Implement dark/light theme toggle in Settings tab
- Add font family and size customization
- Display project logo in header and taskbar
- Open in maximized window with native title bar
- Add 'dashboard' script to package.json
- Update README with dashboard documentation

Closes #XXX
2026-04-12 06:52:54 +00:00
Anish
bc42a34e9a Readme Commit 2026-04-12 12:18:14 +05:30
Anish
f53a89ff88 GUI Created, Dark Mode Created, npm script added, styling changed, Decoupled from Claude made more open source. 2026-04-12 12:16:49 +05:30
Anish
626c18f4c7 feat: add dashboard GUI with theme, font customization, and logo
- Add ecc_dashboard.py - a Tkinter-based GUI for exploring ECC components
- Implement dark/light theme toggle in Settings tab
- Add font family and size customization
- Display project logo in header and taskbar
- Open in maximized window with native title bar
- Add 'dashboard' script to package.json for easy launch
2026-04-12 06:35:14 +00:00
GB
2d044b8032 Apply suggestion from @greptile-apps[bot]
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-11 22:12:15 -07:00
dependabot[bot]
57de4129da build(deps): bump actions/github-script from 8.0.0 to 9.0.0
Bumps [actions/github-script](https://github.com/actions/github-script) from 8.0.0 to 9.0.0.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](ed597411d8...3a2844b7e9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-12 04:52:39 +00:00
dependabot[bot]
5ae63b301f build(deps): bump softprops/action-gh-release from 2.6.1 to 3.0.0
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.6.1 to 3.0.0.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](153bb8e044...b430933298)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-12 04:52:35 +00:00
dependabot[bot]
4b92288a27 build(deps): bump pnpm/action-setup from 5.0.0 to 6.0.0
Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 5.0.0 to 6.0.0.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](fc06bc1257...08c4be7e2e)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-12 04:52:33 +00:00
dependabot[bot]
45faeb90a7 build(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](bbbca2ddaa...043fb46d1a)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-12 04:52:29 +00:00
Balaji Guntur
51abaf0fc0 fix: correct accessibility terminology and code fence in a11y skill and agent
- Fix inverted focus trap terms: Keyboard Traps -> Uncontained Modal Focus with WCAG SC 2.1.2 reference
- Fix Step 1 blocker example: missing keyboard traps -> missing focus containment in modals
- Attach [language] placeholder to opening triple-backtick fence in agent implementation template
2026-04-11 20:30:47 -07:00
seto
5a03922934 feat(hooks,skills): add gateguard fact-forcing pre-action gate
A PreToolUse hook that forces Claude to investigate before editing.
Instead of self-evaluation ("are you sure?"), it demands concrete facts:
importers, public API, data schemas, user instruction.

A/B tested: +2.25 quality points (9.0 vs 6.75) across two independent tasks.

- scripts/hooks/gateguard-fact-force.js — standalone Node.js hook
- skills/gateguard/SKILL.md — skill documentation
- hooks/hooks.json — PreToolUse entries for Edit|Write and Bash

Full package with config: pip install gateguard-ai
Repo: https://github.com/zunoworks/gateguard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 11:41:33 +09:00
Balaji Guntur
33673fb37a Fix PR comments - renamed 'when to activate' to 'when to use', fixed the iOS reference link to current, added iOS HIG link 2026-04-11 13:33:17 -07:00
Balaji Guntur
228be4f8b8 renamed the inclusive-ui-agent to a11y-architect 2026-04-11 13:16:13 -07:00
Balaji Guntur
643d03575a Update the accessibility skill to include related skills 2026-04-11 12:58:21 -07:00
Balaji Guntur
aa8948d5cf Adding accessibility skill to go in with the inclusive-ui-agent 2026-04-11 12:51:21 -07:00
Balaji Guntur
50dc4b0492 feat(a11y):add inclusive-ui architect agent for WCAG 2.2 compliance 2026-04-11 01:44:13 -07:00
122 changed files with 9213 additions and 724 deletions

View File

@@ -6,6 +6,7 @@
"plugins": [
{
"name": "ecc",
"version": "1.10.0",
"source": {
"source": "local",
"path": "../.."

View File

@@ -1,5 +1,5 @@
{
"name": "ecc",
"name": "everything-claude-code",
"owner": {
"name": "Affaan Mustafa",
"email": "me@affaanmustafa.com"
@@ -9,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",

View File

@@ -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": {

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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: |

View File

@@ -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

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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)",

View File

@@ -1,6 +1,6 @@
# Everything Claude Code (ECC) — Agent Instructions
This is a **production-ready AI coding plugin** providing 47 specialized agents, 181 skills, 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
@@ -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)

View File

@@ -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,24 @@ 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.
@@ -235,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
@@ -500,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)
```
@@ -624,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`:
@@ -640,7 +676,7 @@ Or add directly to your `~/.claude/settings.json`:
}
},
"enabledPlugins": {
"ecc@ecc": true
"everything-claude-code@everything-claude-code": true
}
}
```
@@ -703,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
@@ -840,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.
@@ -971,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
@@ -1155,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** |
@@ -1264,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 |

View File

@@ -78,6 +78,17 @@
---
## 最新动态
### v1.10.0 — 表面同步、运营工作流与 ECC 2.0 Alpha2026年4月
- **公共表面已与真实仓库同步** —— 元数据、目录数量、插件清单以及安装文档现在都与实际开源表面保持一致。
- **运营与外向型工作流扩展** —— `brand-voice``social-graph-ranker``customer-billing-ops``google-workspace-ops` 等运营型 skill 已纳入同一系统。
- **媒体与发布工具补齐** —— `manim-video``remotion-video-creation` 以及社媒发布能力让技术讲解和发布流程直接在同一仓库内完成。
- **框架与产品表面继续扩展** —— `nestjs-patterns`、更完整的 Codex/OpenCode 安装表面,以及跨 harness 打包改进,让仓库不再局限于 Claude Code。
- **ECC 2.0 alpha 已进入仓库** —— `ecc2/` 下的 Rust 控制层现已可在本地构建,并提供 `dashboard``start``sessions``status``stop``resume``daemon` 命令。
- **生态加固持续推进** —— AgentShield、ECC Tools 成本控制、计费门户工作与网站刷新仍围绕核心插件持续交付。
## 快速开始
在 2 分钟内快速上手:
@@ -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` 中;

141
agents/a11y-architect.md Normal file
View File

@@ -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.

BIN
assets/images/ecc-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -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 + '):');

View File

@@ -13,21 +13,21 @@ Shows a comprehensive health dashboard for all skills in the portfolio with succ
Run the skill health CLI in dashboard mode:
```bash
ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var 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
```

View File

@@ -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 を設定

View File

@@ -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" と言って起動します
---

View File

@@ -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
```
플러그인에서 사용할 수 있는 모든 에이전트, 커맨드, 스킬을 보여줍니다.

View File

@@ -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>

View File

@@ -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.

View File

@@ -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/ — 始终遵循的指导方针(通用 + 每种语言)

View File

@@ -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 条指令 |

View File

@@ -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" 激活
***

View File

@@ -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

10
ecc2/Cargo.lock generated
View File

@@ -1286,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"
@@ -1294,6 +1303,7 @@ checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]

View File

@@ -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,7 +23,7 @@ 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"] }

913
ecc_dashboard.py Normal file
View File

@@ -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()

View File

@@ -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"
}
}

View File

@@ -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 |

View File

@@ -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,7 +200,7 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:session-activity-tracker\" \"scripts/hooks/session-activity-tracker.js\" \"standard,strict\"",
"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
}
],
@@ -277,13 +212,37 @@
"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)\" 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": [
@@ -292,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",

View File

@@ -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,65 +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/catalog.js",
"scripts/claw.js",
"scripts/codex/merge-codex-config.js",
"scripts/codex/merge-mcp-config.js",
"scripts/doctor.js",
"scripts/ecc.js",
"scripts/gemini-adapt-agents.js",
"scripts/harness-audit.js",
"scripts/hooks/",
"scripts/lib/",
"scripts/claw.js",
"scripts/doctor.js",
"scripts/status.js",
"scripts/sessions-cli.js",
"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",
@@ -116,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",
@@ -137,4 +274,4 @@
"node": ">=18"
},
"packageManager": "yarn@4.9.2+sha512.1fc009bc09d13cfd0e19efa44cbfc2b9cf6ca61482725eb35bbc5e257e093ebf4130db6dfe15d604ff4b79efd8e1e8e99b25fa7d0a6197c9f9826358d4d65c3c"
}
}

78
pyproject.toml Normal file
View File

@@ -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

View File

@@ -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'));

View File

@@ -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,
};

View File

@@ -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) => [

View File

@@ -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 };

View File

@@ -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,
};

View File

@@ -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);
});
}

View File

@@ -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) {
@@ -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`);

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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();

View File

@@ -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 };

View File

@@ -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();

View File

@@ -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 };

View File

@@ -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,
};

View File

@@ -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;
});

View File

@@ -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 };

View File

@@ -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 ────────────────────────────────────────────

View File

@@ -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;
});

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -55,7 +55,7 @@ function sanitizeParamValue(value, depth = 0) {
return '[Truncated]';
}
if (value == null) {
if (value === null || value === undefined) {
return value;
}
@@ -530,7 +530,7 @@ function summarizeInput(toolName, toolInput, filePaths) {
if (toolInput && typeof toolInput === 'object') {
const shallow = {};
for (const [key, value] of Object.entries(toolInput)) {
if (value == null) {
if (value === null || value === undefined) {
continue;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
@@ -545,7 +545,7 @@ function summarizeInput(toolName, toolInput, filePaths) {
}
function summarizeOutput(toolOutput) {
if (toolOutput == null) {
if (toolOutput === null || toolOutput === undefined) {
return '';
}

View File

@@ -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/

View File

@@ -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 };

View File

@@ -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],
{},
)

View File

@@ -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),
]);
});
},
});

View File

@@ -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',
}));
}

View File

@@ -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'
);
}

View File

@@ -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
};

View File

@@ -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"

View File

@@ -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`

View File

@@ -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"
---

120
skills/gateguard/SKILL.md Normal file
View File

@@ -0,0 +1,120 @@
---
name: gateguard
description: Fact-forcing gate that blocks Edit/Write/Bash (including MultiEdit) and demands concrete investigation (importers, data schemas, user instruction) before allowing the action. Measurably improves output quality by +2.25 points vs ungated agents.
origin: community
---
# GateGuard — Fact-Forcing Pre-Action Gate
A PreToolUse hook that forces Claude to investigate before editing. Instead of self-evaluation ("are you sure?"), it demands concrete facts. The act of investigation creates awareness that self-evaluation never did.
## When to Activate
- Working on any codebase where file edits affect multiple modules
- Projects with data files that have specific schemas or date formats
- Teams where AI-generated code must match existing patterns
- Any workflow where Claude tends to guess instead of investigating
## Core Concept
LLM self-evaluation doesn't work. Ask "did you violate any policies?" and the answer is always "no." This is verified experimentally.
But asking "list every file that imports this module" forces the LLM to run Grep and Read. The investigation itself creates context that changes the output.
**Three-stage gate:**
```
1. DENY — block the first Edit/Write/Bash attempt
2. FORCE — tell the model exactly which facts to gather
3. ALLOW — permit retry after facts are presented
```
No competitor does all three. Most stop at deny.
## Evidence
Two independent A/B tests, identical agents, same task:
| Task | Gated | Ungated | Gap |
| --- | --- | --- | --- |
| Analytics module | 8.0/10 | 6.5/10 | +1.5 |
| Webhook validator | 10.0/10 | 7.0/10 | +3.0 |
| **Average** | **9.0** | **6.75** | **+2.25** |
Both agents produce code that runs and passes tests. The difference is design depth.
## Gate Types
### Edit / MultiEdit Gate (first edit per file)
MultiEdit is handled identically — each file in the batch is gated individually.
```
Before editing {file_path}, 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
```
### Write Gate (first new file creation)
```
Before creating {file_path}, 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
```
### Destructive Bash Gate (every destructive command)
Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc.
```
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
```
### Routine Bash Gate (once per session)
```
Quote the user's current instruction verbatim.
```
## Quick Start
### Option A: Use the ECC hook (zero install)
The hook at `scripts/hooks/gateguard-fact-force.js` is included in this plugin. Enable it via hooks.json.
### Option B: Full package with config
```bash
pip install gateguard-ai
gateguard init
```
This adds `.gateguard.yml` for per-project configuration (custom messages, ignore paths, gate toggles).
## Anti-Patterns
- **Don't use self-evaluation instead.** "Are you sure?" always gets "yes." This is experimentally verified.
- **Don't skip the data schema check.** Both A/B test agents assumed ISO-8601 dates when real data used `%Y/%m/%d %H:%M`. Checking data structure (with redacted values) prevents this entire class of bugs.
- **Don't gate every single Bash command.** Routine bash gates once per session. Destructive bash gates every time. This balance avoids slowdown while catching real risks.
## Best Practices
- Let the gate fire naturally. Don't try to pre-answer the gate questions — the investigation itself is what improves quality.
- Customize gate messages for your domain. If your project has specific conventions, add them to the gate prompts.
- Use `.gateguard.yml` to ignore paths like `.venv/`, `node_modules/`, `.git/`.
## Related Skills
- `safety-guard` — Runtime safety checks (complementary, not overlapping)
- `code-reviewer` — Post-edit review (GateGuard is pre-edit investigation)

View File

@@ -33,7 +33,9 @@ def _check_temporal_order(
) -> str | None:
"""Check before_step/after_step constraints. Returns failure reason or None."""
if step.detector.after_step is not None:
after_events = resolved.get(step.detector.after_step, [])
after_events = resolved.get(step.detector.after_step)
if after_events is None:
after_events = classified.get(step.detector.after_step, [])
if not after_events:
return f"after_step '{step.detector.after_step}' not yet detected"
latest_after = max(e.timestamp for e in after_events)

View File

@@ -6,7 +6,7 @@ from unittest.mock import patch
import pytest
from scripts.grader import ComplianceResult, StepResult, grade
from scripts.parser import parse_spec, parse_trace
from scripts.parser import ComplianceSpec, Detector, ObservationEvent, Step, parse_spec, parse_trace
FIXTURES = Path(__file__).parent.parent / "fixtures"
@@ -135,3 +135,63 @@ class TestGradeEdgeCases:
def test_spec_id_in_result(self, mock_cls, tdd_spec, compliant_trace) -> None:
result = grade(tdd_spec, compliant_trace)
assert result.spec_id == "tdd-workflow"
@patch("scripts.grader.classify_events")
def test_after_step_can_reference_later_declared_spec_step(self, mock_cls) -> None:
spec = ComplianceSpec(
id="out-of-order-after-step",
name="Out of order after_step",
source_rule="rules/common/testing.md",
version="1.0",
steps=(
Step(
id="step_a",
description="Occurs after step_b even though it is declared first",
required=True,
detector=Detector(
description="Event A",
after_step="step_b",
),
),
Step(
id="step_b",
description="Reference step declared later",
required=True,
detector=Detector(
description="Event B",
),
),
),
threshold_promote_to_hook=0.5,
)
trace = [
ObservationEvent(
timestamp="2026-03-20T10:00:01Z",
event="tool_complete",
tool="Write",
session="sess-order",
input='{"file_path":"src/b.py"}',
output="step b",
),
ObservationEvent(
timestamp="2026-03-20T10:00:02Z",
event="tool_complete",
tool="Write",
session="sess-order",
input='{"file_path":"src/a.py"}',
output="step a",
),
]
mock_cls.return_value = {
"step_a": [1],
"step_b": [0],
}
result = grade(spec, trace)
step_a = next(step for step in result.steps if step.step_id == "step_a")
step_b = next(step for step in result.steps if step.step_id == "step_b")
assert step_a.detected is True
assert step_a.failure_reason is None
assert step_b.detected is True
assert result.compliance_rate == 1.0

33
src/llm/__init__.py Normal file
View File

@@ -0,0 +1,33 @@
"""
LLM Abstraction Layer
Provider-agnostic interface for multiple LLM backends.
"""
from llm.core.interface import LLMProvider
from llm.core.types import LLMInput, LLMOutput, Message, ToolCall, ToolDefinition, ToolResult
from llm.providers import get_provider
from llm.tools import ToolExecutor, ToolRegistry
from llm.cli.selector import interactive_select
__version__ = "0.1.0"
__all__ = (
"LLMInput",
"LLMOutput",
"LLMProvider",
"Message",
"ToolCall",
"ToolDefinition",
"ToolResult",
"ToolExecutor",
"ToolRegistry",
"get_provider",
"interactive_select",
)
def gui() -> None:
from llm.cli.selector import main
main()

7
src/llm/__main__.py Normal file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env python3
"""Entry point for llm CLI."""
from llm.cli.selector import main
if __name__ == "__main__":
main()

0
src/llm/cli/__init__.py Normal file
View File

154
src/llm/cli/selector.py Normal file
View File

@@ -0,0 +1,154 @@
from __future__ import annotations
import os
import sys
from enum import Enum
class Color(str, Enum):
RESET = "\033[0m"
BOLD = "\033[1m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
BLUE = "\033[94m"
CYAN = "\033[96m"
def print_banner() -> None:
banner = f"""{Color.CYAN}
╔═══════════════════════════════════════════╗
║ LLM Provider Selector ║
║ Provider-agnostic AI interactions ║
╚═══════════════════════════════════════════╝{Color.RESET}"""
print(banner)
def print_providers(providers: list[tuple[str, str]]) -> None:
print(f"\n{Color.BOLD}Available Providers:{Color.RESET}\n")
for i, (name, desc) in enumerate(providers, 1):
print(f" {Color.GREEN}{i}{Color.RESET}. {Color.BOLD}{name}{Color.RESET} - {desc}")
def select_provider(providers: list[tuple[str, str]]) -> str | None:
if not providers:
print("No providers available.")
return None
print_providers(providers)
while True:
try:
choice = input(f"\n{Color.YELLOW}Select provider (1-{len(providers)}): {Color.RESET}").strip()
if not choice:
return None
idx = int(choice) - 1
if 0 <= idx < len(providers):
return providers[idx][0]
print(f"{Color.YELLOW}Invalid selection. Try again.{Color.RESET}")
except ValueError:
print(f"{Color.YELLOW}Please enter a number.{Color.RESET}")
def select_model(models: list[tuple[str, str]]) -> str | None:
if not models:
print("No models available.")
return None
print(f"\n{Color.BOLD}Available Models:{Color.RESET}\n")
for i, (name, desc) in enumerate(models, 1):
print(f" {Color.GREEN}{i}{Color.RESET}. {Color.BOLD}{name}{Color.RESET} - {desc}")
while True:
try:
choice = input(f"\n{Color.YELLOW}Select model (1-{len(models)}): {Color.RESET}").strip()
if not choice:
return None
idx = int(choice) - 1
if 0 <= idx < len(models):
return models[idx][0]
print(f"{Color.YELLOW}Invalid selection. Try again.{Color.RESET}")
except ValueError:
print(f"{Color.YELLOW}Please enter a number.{Color.RESET}")
def save_config(provider: str, model: str, persist: bool = False) -> None:
config = f"LLM_PROVIDER={provider}\nLLM_MODEL={model}\n"
env_file = ".llm.env"
with open(env_file, "w") as f:
f.write(config)
print(f"\n{Color.GREEN}{Color.RESET} Config saved to {Color.CYAN}{env_file}{Color.RESET}")
if persist:
os.environ["LLM_PROVIDER"] = provider
os.environ["LLM_MODEL"] = model
print(f"{Color.GREEN}{Color.RESET} Config loaded to current session")
def interactive_select(
providers: list[tuple[str, str]] | None = None,
models_per_provider: dict[str, list[tuple[str, str]]] | None = None,
persist: bool = False,
) -> tuple[str, str] | None:
print_banner()
if providers is None:
providers = [
("claude", "Anthropic Claude ( Sonnet, Opus, Haiku)"),
("openai", "OpenAI GPT (4o, 4o-mini, 3.5-turbo)"),
("ollama", "Local Ollama models"),
]
if models_per_provider is None:
models_per_provider = {
"claude": [
("claude-opus-4-5", "Claude Opus 4.5 - Most capable"),
("claude-sonnet-4-7", "Claude Sonnet 4.7 - Balanced"),
("claude-haiku-4-7", "Claude Haiku 4.7 - Fast"),
],
"openai": [
("gpt-4o", "GPT-4o - Most capable"),
("gpt-4o-mini", "GPT-4o-mini - Fast & affordable"),
("gpt-4-turbo", "GPT-4 Turbo - Legacy powerful"),
("gpt-3.5-turbo", "GPT-3.5 - Legacy fast"),
],
"ollama": [
("llama3.2", "Llama 3.2 - General purpose"),
("mistral", "Mistral - Fast & efficient"),
("codellama", "CodeLlama - Code specialized"),
],
}
provider = select_provider(providers)
if not provider:
return None
models = models_per_provider.get(provider, [])
model = select_model(models)
if not model:
return None
print(f"\n{Color.GREEN}Selected: {Color.BOLD}{provider}{Color.RESET} / {Color.BOLD}{model}{Color.RESET}")
save_config(provider, model, persist)
return (provider, model)
def main() -> None:
result = interactive_select(persist=True)
if result:
print(f"\n{Color.GREEN}Ready to use!{Color.RESET}")
print(f" export LLM_PROVIDER={result[0]}")
print(f" export LLM_MODEL={result[1]}")
else:
print("\nSelection cancelled.")
sys.exit(0)
if __name__ == "__main__":
main()

1
src/llm/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Core module for LLM abstraction layer."""

60
src/llm/core/interface.py Normal file
View File

@@ -0,0 +1,60 @@
"""LLM Provider interface definition."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
from llm.core.types import LLMInput, LLMOutput, ModelInfo, ProviderType
class LLMProvider(ABC):
provider_type: ProviderType
@abstractmethod
def generate(self, input: LLMInput) -> LLMOutput: ...
@abstractmethod
def list_models(self) -> list[ModelInfo]: ...
@abstractmethod
def validate_config(self) -> bool: ...
def supports_tools(self) -> bool:
return True
def supports_vision(self) -> bool:
return False
def get_default_model(self) -> str:
raise NotImplementedError(f"{self.__class__.__name__} must implement get_default_model")
class LLMError(Exception):
def __init__(
self,
message: str,
provider: ProviderType | None = None,
code: str | None = None,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.message = message
self.provider = provider
self.code = code
self.details = details or {}
class AuthenticationError(LLMError): ...
class RateLimitError(LLMError): ...
class ContextLengthError(LLMError): ...
class ModelNotFoundError(LLMError): ...
class ToolExecutionError(LLMError): ...

146
src/llm/core/types.py Normal file
View File

@@ -0,0 +1,146 @@
"""Core type definitions for LLM abstraction layer."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class Role(str, Enum):
SYSTEM = "system"
USER = "user"
ASSISTANT = "assistant"
TOOL = "tool"
class ProviderType(str, Enum):
CLAUDE = "claude"
OPENAI = "openai"
OLLAMA = "ollama"
@dataclass(frozen=True)
class Message:
role: Role
content: str
name: str | None = None
tool_call_id: str | None = None
tool_calls: list[ToolCall] | None = None
def to_dict(self) -> dict[str, Any]:
result: dict[str, Any] = {"role": self.role.value, "content": self.content}
if self.name:
result["name"] = self.name
if self.tool_call_id:
result["tool_call_id"] = self.tool_call_id
if self.tool_calls:
result["tool_calls"] = [
{"id": tc.id, "function": {"name": tc.name, "arguments": tc.arguments}}
for tc in self.tool_calls
]
return result
@dataclass(frozen=True)
class ToolDefinition:
name: str
description: str
parameters: dict[str, Any]
strict: bool = True
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
"strict": self.strict,
}
@dataclass(frozen=True)
class ToolCall:
id: str
name: str
arguments: dict[str, Any]
@dataclass(frozen=True)
class ToolResult:
tool_call_id: str
content: str
is_error: bool = False
@dataclass(frozen=True)
class LLMInput:
messages: list[Message]
model: str | None = None
temperature: float = 1.0
max_tokens: int | None = None
tools: list[ToolDefinition] | None = None
stream: bool = False
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
result: dict[str, Any] = {
"messages": [msg.to_dict() for msg in self.messages],
"temperature": self.temperature,
"stream": self.stream,
}
if self.model:
result["model"] = self.model
if self.max_tokens is not None:
result["max_tokens"] = self.max_tokens
if self.tools:
result["tools"] = [tool.to_dict() for tool in self.tools]
return result | self.metadata
@dataclass(frozen=True)
class LLMOutput:
content: str
tool_calls: list[ToolCall] | None = None
model: str | None = None
usage: dict[str, int] | None = None
stop_reason: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@property
def has_tool_calls(self) -> bool:
return bool(self.tool_calls)
def to_dict(self) -> dict[str, Any]:
result: dict[str, Any] = {"content": self.content}
if self.tool_calls:
result["tool_calls"] = [
{"id": tc.id, "name": tc.name, "arguments": tc.arguments}
for tc in self.tool_calls
]
if self.model:
result["model"] = self.model
if self.usage:
result["usage"] = self.usage
if self.stop_reason:
result["stop_reason"] = self.stop_reason
return result | self.metadata
@dataclass(frozen=True)
class ModelInfo:
name: str
provider: ProviderType
supports_tools: bool = True
supports_vision: bool = False
max_tokens: int | None = None
context_window: int | None = None
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"provider": self.provider.value,
"supports_tools": self.supports_tools,
"supports_vision": self.supports_vision,
"max_tokens": self.max_tokens,
"context_window": self.context_window,
}

View File

@@ -0,0 +1,13 @@
"""Prompt module for prompt building and normalization."""
from llm.prompt.builder import PromptBuilder, adapt_messages_for_provider, get_provider_builder
from llm.prompt.templates import TEMPLATES, get_template, get_template_or_default
__all__ = (
"PromptBuilder",
"TEMPLATES",
"adapt_messages_for_provider",
"get_provider_builder",
"get_template",
"get_template_or_default",
)

102
src/llm/prompt/builder.py Normal file
View File

@@ -0,0 +1,102 @@
"""Prompt builder for normalizing prompts across providers."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from llm.core.types import LLMInput, Message, Role, ToolDefinition
from llm.providers.claude import ClaudeProvider
from llm.providers.openai import OpenAIProvider
from llm.providers.ollama import OllamaProvider
@dataclass
class PromptConfig:
system_template: str | None = None
user_template: str | None = None
include_tools_in_system: bool = True
tool_format: str = "native"
class PromptBuilder:
def __init__(self, config: PromptConfig | None = None) -> None:
self.config = config or PromptConfig()
def build(self, messages: list[Message], tools: list[ToolDefinition] | None = None) -> list[Message]:
if not messages:
return []
result: list[Message] = []
system_parts: list[str] = []
if self.config.system_template:
system_parts.append(self.config.system_template)
if tools and self.config.include_tools_in_system:
tools_desc = self._format_tools(tools)
system_parts.append(f"\n\n## Available Tools\n{tools_desc}")
if messages[0].role == Role.SYSTEM:
system_parts.insert(0, messages[0].content)
result.insert(0, Message(role=Role.SYSTEM, content="\n\n".join(system_parts)))
result.extend(messages[1:])
else:
if system_parts:
result.insert(0, Message(role=Role.SYSTEM, content="\n\n".join(system_parts)))
result.extend(messages)
return result
def _format_tools(self, tools: list[ToolDefinition]) -> str:
lines = []
for tool in tools:
lines.append(f"### {tool.name}")
lines.append(tool.description)
if tool.parameters:
lines.append("Parameters:")
lines.append(self._format_parameters(tool.parameters))
return "\n".join(lines)
def _format_parameters(self, params: dict[str, Any]) -> str:
if "properties" not in params:
return str(params)
lines = []
required = params.get("required", [])
for name, spec in params["properties"].items():
prop_type = spec.get("type", "any")
desc = spec.get("description", "")
required_mark = "(required)" if name in required else "(optional)"
lines.append(f" - {name}: {prop_type} {required_mark} - {desc}")
return "\n".join(lines) if lines else str(params)
_PROVIDER_TEMPLATE_MAP: dict[str, dict[str, Any]] = {
"claude": {
"include_tools_in_system": False,
"tool_format": "anthropic",
},
"openai": {
"include_tools_in_system": False,
"tool_format": "openai",
},
"ollama": {
"include_tools_in_system": True,
"tool_format": "text",
},
}
def get_provider_builder(provider_name: str) -> PromptBuilder:
config_dict = _PROVIDER_TEMPLATE_MAP.get(provider_name.lower(), {})
config = PromptConfig(**config_dict)
return PromptBuilder(config)
def adapt_messages_for_provider(
messages: list[Message],
provider: str,
tools: list[ToolDefinition] | None = None,
) -> list[Message]:
builder = get_provider_builder(provider)
return builder.build(messages, tools)

View File

@@ -0,0 +1 @@
# Templates module for provider-specific prompt templates

View File

@@ -0,0 +1,14 @@
"""Provider adapters for multiple LLM backends."""
from llm.providers.claude import ClaudeProvider
from llm.providers.openai import OpenAIProvider
from llm.providers.ollama import OllamaProvider
from llm.providers.resolver import get_provider, register_provider
__all__ = (
"ClaudeProvider",
"OpenAIProvider",
"OllamaProvider",
"get_provider",
"register_provider",
)

105
src/llm/providers/claude.py Normal file
View File

@@ -0,0 +1,105 @@
"""Claude provider adapter."""
from __future__ import annotations
import os
from typing import Any
from anthropic import Anthropic
from llm.core.interface import (
AuthenticationError,
ContextLengthError,
LLMProvider,
RateLimitError,
)
from llm.core.types import LLMInput, LLMOutput, Message, ModelInfo, ProviderType, ToolCall
class ClaudeProvider(LLMProvider):
provider_type = ProviderType.CLAUDE
def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None:
self.client = Anthropic(api_key=api_key or os.environ.get("ANTHROPIC_API_KEY"), base_url=base_url)
self._models = [
ModelInfo(
name="claude-opus-4-5",
provider=ProviderType.CLAUDE,
supports_tools=True,
supports_vision=True,
max_tokens=8192,
context_window=200000,
),
ModelInfo(
name="claude-sonnet-4-7",
provider=ProviderType.CLAUDE,
supports_tools=True,
supports_vision=True,
max_tokens=8192,
context_window=200000,
),
ModelInfo(
name="claude-haiku-4-7",
provider=ProviderType.CLAUDE,
supports_tools=True,
supports_vision=False,
max_tokens=4096,
context_window=200000,
),
]
def generate(self, input: LLMInput) -> LLMOutput:
try:
params: dict[str, Any] = {
"model": input.model or "claude-sonnet-4-7",
"messages": [msg.to_dict() for msg in input.messages],
"temperature": input.temperature,
}
if input.max_tokens:
params["max_tokens"] = input.max_tokens
else:
params["max_tokens"] = 8192 # required by Anthropic API
if input.tools:
params["tools"] = [tool.to_dict() for tool in input.tools]
response = self.client.messages.create(**params)
tool_calls = None
if response.content and hasattr(response.content[0], "type"):
if response.content[0].type == "tool_use":
tool_calls = [
ToolCall(
id=getattr(response.content[0], "id", ""),
name=getattr(response.content[0], "name", ""),
arguments=getattr(response.content[0].input, "__dict__", {}),
)
]
return LLMOutput(
content=response.content[0].text if response.content else "",
tool_calls=tool_calls,
model=response.model,
usage={
"input_tokens": response.usage.input_tokens,
"output_tokens": response.usage.output_tokens,
},
stop_reason=response.stop_reason,
)
except Exception as e:
msg = str(e)
if "401" in msg or "authentication" in msg.lower():
raise AuthenticationError(msg, provider=ProviderType.CLAUDE) from e
if "429" in msg or "rate_limit" in msg.lower():
raise RateLimitError(msg, provider=ProviderType.CLAUDE) from e
if "context" in msg.lower() and "length" in msg.lower():
raise ContextLengthError(msg, provider=ProviderType.CLAUDE) from e
raise
def list_models(self) -> list[ModelInfo]:
return self._models.copy()
def validate_config(self) -> bool:
return bool(self.client.api_key)
def get_default_model(self) -> str:
return "claude-sonnet-4-7"

112
src/llm/providers/ollama.py Normal file
View File

@@ -0,0 +1,112 @@
"""Ollama provider adapter for local models."""
from __future__ import annotations
import os
from typing import Any
from llm.core.interface import (
AuthenticationError,
ContextLengthError,
LLMProvider,
RateLimitError,
)
from llm.core.types import LLMInput, LLMOutput, Message, ModelInfo, ProviderType, ToolCall
class OllamaProvider(LLMProvider):
provider_type = ProviderType.OLLAMA
def __init__(
self,
base_url: str | None = None,
default_model: str | None = None,
) -> None:
self.base_url = base_url or os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
self.default_model = default_model or os.environ.get("OLLAMA_MODEL", "llama3.2")
self._models = [
ModelInfo(
name="llama3.2",
provider=ProviderType.OLLAMA,
supports_tools=False,
supports_vision=False,
max_tokens=4096,
context_window=128000,
),
ModelInfo(
name="mistral",
provider=ProviderType.OLLAMA,
supports_tools=False,
supports_vision=False,
max_tokens=4096,
context_window=8192,
),
ModelInfo(
name="codellama",
provider=ProviderType.OLLAMA,
supports_tools=False,
supports_vision=False,
max_tokens=4096,
context_window=16384,
),
]
def generate(self, input: LLMInput) -> LLMOutput:
import urllib.request
import json
try:
url = f"{self.base_url}/api/chat"
model = input.model or self.default_model
payload: dict[str, Any] = {
"model": model,
"messages": [msg.to_dict() for msg in input.messages],
"stream": False,
}
if input.temperature != 1.0:
payload["options"] = {"temperature": input.temperature}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as response:
result = json.loads(response.read().decode("utf-8"))
content = result.get("message", {}).get("content", "")
tool_calls = None
if result.get("message", {}).get("tool_calls"):
tool_calls = [
ToolCall(
id=tc.get("id", ""),
name=tc.get("function", {}).get("name", ""),
arguments=tc.get("function", {}).get("arguments", {}),
)
for tc in result["message"]["tool_calls"]
]
return LLMOutput(
content=content,
tool_calls=tool_calls,
model=model,
stop_reason=result.get("done_reason"),
)
except Exception as e:
msg = str(e)
if "401" in msg or "connection" in msg.lower():
raise AuthenticationError(f"Ollama connection failed: {msg}", provider=ProviderType.OLLAMA) from e
if "429" in msg or "rate_limit" in msg.lower():
raise RateLimitError(msg, provider=ProviderType.OLLAMA) from e
if "context" in msg.lower() and "length" in msg.lower():
raise ContextLengthError(msg, provider=ProviderType.OLLAMA) from e
raise
def list_models(self) -> list[ModelInfo]:
return self._models.copy()
def validate_config(self) -> bool:
return bool(self.base_url)
def get_default_model(self) -> str:
return self.default_model

114
src/llm/providers/openai.py Normal file
View File

@@ -0,0 +1,114 @@
"""OpenAI provider adapter."""
from __future__ import annotations
import json
import os
from typing import Any
from openai import OpenAI
from llm.core.interface import (
AuthenticationError,
ContextLengthError,
LLMProvider,
RateLimitError,
)
from llm.core.types import LLMInput, LLMOutput, Message, ModelInfo, ProviderType, ToolCall
class OpenAIProvider(LLMProvider):
provider_type = ProviderType.OPENAI
def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None:
self.client = OpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY"), base_url=base_url)
self._models = [
ModelInfo(
name="gpt-4o",
provider=ProviderType.OPENAI,
supports_tools=True,
supports_vision=True,
max_tokens=4096,
context_window=128000,
),
ModelInfo(
name="gpt-4o-mini",
provider=ProviderType.OPENAI,
supports_tools=True,
supports_vision=True,
max_tokens=4096,
context_window=128000,
),
ModelInfo(
name="gpt-4-turbo",
provider=ProviderType.OPENAI,
supports_tools=True,
supports_vision=True,
max_tokens=4096,
context_window=128000,
),
ModelInfo(
name="gpt-3.5-turbo",
provider=ProviderType.OPENAI,
supports_tools=True,
supports_vision=False,
max_tokens=4096,
context_window=16385,
),
]
def generate(self, input: LLMInput) -> LLMOutput:
try:
params: dict[str, Any] = {
"model": input.model or "gpt-4o-mini",
"messages": [msg.to_dict() for msg in input.messages],
"temperature": input.temperature,
}
if input.max_tokens:
params["max_tokens"] = input.max_tokens
if input.tools:
params["tools"] = [tool.to_dict() for tool in input.tools]
response = self.client.chat.completions.create(**params)
choice = response.choices[0]
tool_calls = None
if choice.message.tool_calls:
tool_calls = [
ToolCall(
id=tc.id or "",
name=tc.function.name,
arguments={} if not tc.function.arguments else json.loads(tc.function.arguments),
)
for tc in choice.message.tool_calls
]
return LLMOutput(
content=choice.message.content or "",
tool_calls=tool_calls,
model=response.model,
usage={
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
},
stop_reason=choice.finish_reason,
)
except Exception as e:
msg = str(e)
if "401" in msg or "authentication" in msg.lower():
raise AuthenticationError(msg, provider=ProviderType.OPENAI) from e
if "429" in msg or "rate_limit" in msg.lower():
raise RateLimitError(msg, provider=ProviderType.OPENAI) from e
if "context" in msg.lower() and "length" in msg.lower():
raise ContextLengthError(msg, provider=ProviderType.OPENAI) from e
raise
def list_models(self) -> list[ModelInfo]:
return self._models.copy()
def validate_config(self) -> bool:
return bool(self.client.api_key)
def get_default_model(self) -> str:
return "gpt-4o-mini"

View File

@@ -0,0 +1,39 @@
"""Provider factory and resolver."""
from __future__ import annotations
import os
from llm.core.interface import LLMProvider
from llm.core.types import ProviderType
from llm.providers.claude import ClaudeProvider
from llm.providers.openai import OpenAIProvider
from llm.providers.ollama import OllamaProvider
_PROVIDER_MAP: dict[ProviderType, type[LLMProvider]] = {
ProviderType.CLAUDE: ClaudeProvider,
ProviderType.OPENAI: OpenAIProvider,
ProviderType.OLLAMA: OllamaProvider,
}
def get_provider(provider_type: ProviderType | str | None = None, **kwargs: str) -> LLMProvider:
if provider_type is None:
provider_type = os.environ.get("LLM_PROVIDER", "claude").lower()
if isinstance(provider_type, str):
try:
provider_type = ProviderType(provider_type)
except ValueError:
raise ValueError(f"Unknown provider type: {provider_type}. Valid types: {[p.value for p in ProviderType]}")
provider_cls = _PROVIDER_MAP.get(provider_type)
if not provider_cls:
raise ValueError(f"No provider registered for type: {provider_type}")
return provider_cls(**kwargs)
def register_provider(provider_type: ProviderType, provider_cls: type[LLMProvider]) -> None:
_PROVIDER_MAP[provider_type] = provider_cls

View File

@@ -0,0 +1,9 @@
"""Tools module for tool/function calling abstraction."""
from llm.tools.executor import ReActAgent, ToolExecutor, ToolRegistry
__all__ = (
"ReActAgent",
"ToolExecutor",
"ToolRegistry",
)

116
src/llm/tools/executor.py Normal file
View File

@@ -0,0 +1,116 @@
"""Tool executor for handling tool calls from LLM responses."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Callable
from llm.core.interface import ToolExecutionError
from llm.core.types import LLMInput, LLMOutput, Message, Role, ToolCall, ToolDefinition, ToolResult
ToolFunc = Callable[..., Any]
class ToolRegistry:
def __init__(self) -> None:
self._tools: dict[str, ToolFunc] = {}
self._definitions: dict[str, ToolDefinition] = {}
def register(self, definition: ToolDefinition, func: ToolFunc) -> None:
self._tools[definition.name] = func
self._definitions[definition.name] = definition
def get(self, name: str) -> ToolFunc | None:
return self._tools.get(name)
def get_definition(self, name: str) -> ToolDefinition | None:
return self._definitions.get(name)
def list_tools(self) -> list[ToolDefinition]:
return list(self._definitions.values())
def has(self, name: str) -> bool:
return name in self._tools
class ToolExecutor:
def __init__(self, registry: ToolRegistry | None = None) -> None:
self.registry = registry or ToolRegistry()
def execute(self, tool_call: ToolCall) -> ToolResult:
func = self.registry.get(tool_call.name)
if not func:
return ToolResult(
tool_call_id=tool_call.id,
content=f"Error: Tool '{tool_call.name}' not found",
is_error=True,
)
try:
result = func(**tool_call.arguments)
content = result if isinstance(result, str) else str(result)
return ToolResult(tool_call_id=tool_call.id, content=content)
except Exception as e:
return ToolResult(
tool_call_id=tool_call.id,
content=f"Error executing {tool_call.name}: {e}",
is_error=True,
)
def execute_all(self, tool_calls: list[ToolCall]) -> list[ToolResult]:
return [self.execute(tc) for tc in tool_calls]
class ReActAgent:
def __init__(
self,
provider: Any,
executor: ToolExecutor,
max_iterations: int = 10,
) -> None:
self.provider = provider
self.executor = executor
self.max_iterations = max_iterations
async def run(self, input: LLMInput) -> LLMOutput:
messages = list(input.messages)
tools = input.tools or []
for _ in range(self.max_iterations):
input_copy = LLMInput(
messages=messages,
model=input.model,
temperature=input.temperature,
max_tokens=input.max_tokens,
tools=tools,
)
output = self.provider.generate(input_copy)
if not output.has_tool_calls:
return output
messages.append(
Message(
role=Role.ASSISTANT,
content=output.content or "",
tool_calls=output.tool_calls,
)
)
results = self.executor.execute_all(output.tool_calls)
for result in results:
messages.append(
Message(
role=Role.TOOL,
content=result.content,
tool_call_id=result.tool_call_id,
)
)
return LLMOutput(
content="Max iterations reached",
stop_reason="max_iterations",
)

0
tests/__init__.py Normal file
View File

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* Validate workflow security guardrails for privileged GitHub Actions events.
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const SCRIPT_PATH = path.join(__dirname, '..', '..', 'scripts', 'ci', 'validate-workflow-security.js');
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runValidator(files) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-workflow-security-'));
try {
for (const [name, contents] of Object.entries(files)) {
fs.writeFileSync(path.join(tempDir, name), contents);
}
return spawnSync('node', [SCRIPT_PATH], {
encoding: 'utf8',
env: {
...process.env,
ECC_WORKFLOWS_DIR: tempDir,
},
});
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
function run() {
console.log('\n=== Testing workflow security validation ===\n');
let passed = 0;
let failed = 0;
if (test('allows safe workflow_run workflow that only checks out the base repository', () => {
const result = runValidator({
'safe.yml': `name: Safe\non:\n workflow_run:\n workflows: ["CI"]\n types: [completed]\njobs:\n repair:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: echo safe\n`,
});
assert.strictEqual(result.status, 0, result.stderr || result.stdout);
})) passed++; else failed++;
if (test('rejects workflow_run checkout using github.event.workflow_run.head_branch', () => {
const result = runValidator({
'unsafe-workflow-run.yml': `name: Unsafe\non:\n workflow_run:\n workflows: ["CI"]\n types: [completed]\njobs:\n repair:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n with:\n ref: \${{ github.event.workflow_run.head_branch }}\n`,
});
assert.notStrictEqual(result.status, 0, 'Expected validator to fail');
assert.match(result.stderr, /workflow_run must not checkout an untrusted workflow_run head ref\/repository/);
assert.match(result.stderr, /head_branch/);
})) passed++; else failed++;
if (test('rejects workflow_run checkout using github.event.workflow_run.head_repository.full_name', () => {
const result = runValidator({
'unsafe-repository.yml': `name: Unsafe\non:\n workflow_run:\n workflows: ["CI"]\n types: [completed]\njobs:\n repair:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n with:\n repository: \${{ github.event.workflow_run.head_repository.full_name }}\n`,
});
assert.notStrictEqual(result.status, 0, 'Expected validator to fail');
assert.match(result.stderr, /head_repository\.full_name/);
})) passed++; else failed++;
if (test('rejects pull_request_target checkout using github.event.pull_request.head.sha', () => {
const result = runValidator({
'unsafe-pr-target.yml': `name: Unsafe\non:\n pull_request_target:\n branches: [main]\njobs:\n inspect:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n with:\n ref: \${{ github.event.pull_request.head.sha }}\n`,
});
assert.notStrictEqual(result.status, 0, 'Expected validator to fail');
assert.match(result.stderr, /pull_request_target must not checkout an untrusted pull_request head ref\/repository/);
assert.match(result.stderr, /pull_request\.head\.sha/);
})) passed++; else failed++;
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
run();

4
tests/conftest.py Normal file
View File

@@ -0,0 +1,4 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))

View File

@@ -0,0 +1,51 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..', '..');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed++;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
failed++;
}
}
const publicInstallDocs = [
'README.md',
'README.zh-CN.md',
'docs/pt-BR/README.md',
'docs/ja-JP/skills/configure-ecc/SKILL.md',
'docs/zh-CN/skills/configure-ecc/SKILL.md',
];
console.log('\n=== Testing public install identifiers ===\n');
for (const relativePath of publicInstallDocs) {
const content = fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
test(`${relativePath} does not use the stale ecc@ecc plugin identifier`, () => {
assert.ok(!content.includes('ecc@ecc'));
});
test(`${relativePath} documents the canonical marketplace plugin identifier`, () => {
assert.ok(content.includes('everything-claude-code@everything-claude-code'));
});
}
if (failed > 0) {
console.log(`\nFailed: ${failed}`);
process.exit(1);
}
console.log(`\nPassed: ${passed}`);

View File

@@ -0,0 +1,114 @@
/**
* Tests for consolidated Bash hook dispatchers.
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const preDispatcher = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'pre-bash-dispatcher.js');
const postDispatcher = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'post-bash-dispatcher.js');
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runScript(scriptPath, input, env = {}) {
return spawnSync('node', [scriptPath], {
input: typeof input === 'string' ? input : JSON.stringify(input),
encoding: 'utf8',
env: {
...process.env,
...env,
},
timeout: 10000,
});
}
function runTests() {
console.log('\n=== Testing Bash hook dispatchers ===\n');
let passed = 0;
let failed = 0;
if (test('pre dispatcher blocks --no-verify before other Bash checks', () => {
const input = { tool_input: { command: 'git commit --no-verify -m "x"' } };
const result = runScript(preDispatcher, input, { ECC_HOOK_PROFILE: 'strict' });
assert.strictEqual(result.status, 2, 'Expected dispatcher to block git hook bypass');
assert.ok(result.stderr.includes('--no-verify'), 'Expected block-no-verify reason in stderr');
assert.strictEqual(result.stdout, '', 'Blocking hook should not pass through stdout');
})) passed++; else failed++;
if (test('pre dispatcher still honors per-hook disable flags', () => {
const input = { tool_input: { command: 'git push origin main' } };
const enabled = runScript(preDispatcher, input, { ECC_HOOK_PROFILE: 'strict' });
assert.strictEqual(enabled.status, 0);
assert.ok(enabled.stderr.includes('Review changes before push'), 'Expected git push reminder when enabled');
const disabled = runScript(preDispatcher, input, {
ECC_HOOK_PROFILE: 'strict',
ECC_DISABLED_HOOKS: 'pre:bash:git-push-reminder',
});
assert.strictEqual(disabled.status, 0);
assert.ok(!disabled.stderr.includes('Review changes before push'), 'Disabled hook should not emit reminder');
})) passed++; else failed++;
if (test('pre dispatcher respects hook profiles inside the consolidated path', () => {
const input = { tool_input: { command: 'git push origin main' } };
const result = runScript(preDispatcher, input, { ECC_HOOK_PROFILE: 'minimal' });
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stderr, '', 'Strict-only reminders should stay disabled in minimal profile');
assert.strictEqual(result.stdout, JSON.stringify(input));
})) passed++; else failed++;
if (test('post dispatcher writes both bash audit and cost logs in one pass', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-bash-dispatcher-'));
const payload = { tool_input: { command: 'npm publish --token=$PUBLISH_TOKEN' } };
try {
const result = runScript(postDispatcher, payload, {
HOME: homeDir,
USERPROFILE: homeDir,
});
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, JSON.stringify(payload));
const auditLog = fs.readFileSync(path.join(homeDir, '.claude', 'bash-commands.log'), 'utf8');
const costLog = fs.readFileSync(path.join(homeDir, '.claude', 'cost-tracker.log'), 'utf8');
assert.ok(auditLog.includes('--token=<REDACTED>'));
assert.ok(costLog.includes('tool=Bash command=npm publish --token=<REDACTED>'));
assert.ok(!auditLog.includes('$PUBLISH_TOKEN'));
assert.ok(!costLog.includes('$PUBLISH_TOKEN'));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('post dispatcher preserves PR-created hints after consolidated execution', () => {
const payload = {
tool_input: { command: 'gh pr create --title "Fix bug" --body "desc"' },
tool_output: { output: 'https://github.com/owner/repo/pull/42\n' },
};
const result = runScript(postDispatcher, payload);
assert.strictEqual(result.status, 0);
assert.ok(result.stderr.includes('PR created: https://github.com/owner/repo/pull/42'));
assert.ok(result.stderr.includes('gh pr review 42 --repo owner/repo'));
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View File

@@ -0,0 +1,132 @@
/**
* Tests for scripts/hooks/block-no-verify.js via run-with-flags.js
*/
const assert = require('assert');
const path = require('path');
const { spawnSync } = require('child_process');
const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js');
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (error) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runHook(input, env = {}) {
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
const result = spawnSync('node', [runner, 'pre:bash:block-no-verify', 'scripts/hooks/block-no-verify.js', 'minimal,standard,strict'], {
input: rawInput,
encoding: 'utf8',
env: {
...process.env,
ECC_HOOK_PROFILE: 'standard',
...env
},
timeout: 15000,
stdio: ['pipe', 'pipe', 'pipe']
});
return {
code: Number.isInteger(result.status) ? result.status : 1,
stdout: result.stdout || '',
stderr: result.stderr || ''
};
}
let passed = 0;
let failed = 0;
console.log('\nblock-no-verify hook tests');
console.log('─'.repeat(50));
// --- Basic allow/block ---
if (test('allows plain git commit', () => {
const r = runHook({ tool_input: { command: 'git commit -m "hello"' } });
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
if (test('blocks --no-verify on git commit', () => {
const r = runHook({ tool_input: { command: 'git commit --no-verify -m "msg"' } });
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
assert.ok(r.stderr.includes('BLOCKED'), `stderr should contain BLOCKED: ${r.stderr}`);
})) passed++; else failed++;
if (test('blocks -n shorthand on git commit', () => {
const r = runHook({ tool_input: { command: 'git commit -n -m "msg"' } });
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
assert.ok(r.stderr.includes('BLOCKED'), `stderr should contain BLOCKED: ${r.stderr}`);
})) passed++; else failed++;
if (test('blocks core.hooksPath override', () => {
const r = runHook({ tool_input: { command: 'git -c core.hooksPath=/dev/null commit -m "msg"' } });
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
assert.ok(r.stderr.includes('core.hooksPath'), `stderr should mention core.hooksPath: ${r.stderr}`);
})) passed++; else failed++;
// --- Chained command false positive prevention (Comment 2) ---
if (test('does not false-positive on -n belonging to git log in a chain', () => {
const r = runHook({ tool_input: { command: 'git log -n 10 && git commit -m "msg"' } });
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
if (test('does not false-positive on --no-verify in a prior non-git command', () => {
const r = runHook({ tool_input: { command: 'echo --no-verify && git commit -m "msg"' } });
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
if (test('still blocks --no-verify on the git commit part of a chain', () => {
const r = runHook({ tool_input: { command: 'git log -n 5 && git commit --no-verify -m "msg"' } });
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
})) passed++; else failed++;
// --- Subcommand detection (Comment 4) ---
if (test('does not misclassify "commit" as subcommand when it is an argument to push', () => {
// "git push origin commit" — "commit" is a refspec arg, not the subcommand
const r = runHook({ tool_input: { command: 'git push origin commit' } });
// This should detect "push" as the subcommand, not "commit"
// Either way it should not block since there's no --no-verify
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
// --- Blocks on push --no-verify ---
if (test('blocks --no-verify on git push', () => {
const r = runHook({ tool_input: { command: 'git push --no-verify' } });
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
assert.ok(r.stderr.includes('git push'), `stderr should mention git push: ${r.stderr}`);
})) passed++; else failed++;
// --- Non-git commands pass through ---
if (test('allows non-git commands', () => {
const r = runHook({ tool_input: { command: 'npm test' } });
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
// --- Plain text input (not JSON) ---
if (test('handles plain text input', () => {
const r = runHook('git commit -m "hello"');
assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
if (test('blocks plain text input with --no-verify', () => {
const r = runHook('git commit --no-verify -m "msg"');
assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`);
})) passed++; else failed++;
console.log('─'.repeat(50));
console.log(`Passed: ${passed} Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,238 @@
/**
* Tests for scripts/hooks/ecc-context-monitor.js
*
* Run with: node tests/hooks/ecc-context-monitor.test.js
*/
const assert = require('assert');
const { run, evaluateConditions, detectLoop, severityLabel } = require('../../scripts/hooks/ecc-context-monitor');
// Test helper
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (err) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing ecc-context-monitor.js ===\n');
let passed = 0;
let failed = 0;
// evaluateConditions — context warnings
console.log('evaluateConditions (context):');
if (
test('remaining 20% triggers CRITICAL context warning', () => {
const warnings = evaluateConditions({ context_remaining_pct: 20 });
const ctx = warnings.find(w => w.type === 'context');
assert.ok(ctx, 'Expected a context warning');
assert.strictEqual(ctx.severity, 3);
assert.ok(ctx.message.includes('CRITICAL'), 'Message should contain CRITICAL');
})
)
passed++;
else failed++;
if (
test('remaining 30% triggers WARNING context warning', () => {
const warnings = evaluateConditions({ context_remaining_pct: 30 });
const ctx = warnings.find(w => w.type === 'context');
assert.ok(ctx, 'Expected a context warning');
assert.strictEqual(ctx.severity, 2);
assert.ok(ctx.message.includes('WARNING'), 'Message should contain WARNING');
})
)
passed++;
else failed++;
if (
test('remaining 50% triggers no context warning', () => {
const warnings = evaluateConditions({ context_remaining_pct: 50 });
const ctx = warnings.find(w => w.type === 'context');
assert.strictEqual(ctx, undefined);
})
)
passed++;
else failed++;
// evaluateConditions — cost warnings
console.log('\nevaluateConditions (cost):');
if (
test('cost $55 triggers CRITICAL cost warning', () => {
const warnings = evaluateConditions({ total_cost_usd: 55 });
const cost = warnings.find(w => w.type === 'cost');
assert.ok(cost, 'Expected a cost warning');
assert.strictEqual(cost.severity, 3);
assert.ok(cost.message.includes('CRITICAL'), 'Message should contain CRITICAL');
})
)
passed++;
else failed++;
if (
test('cost $12 triggers WARNING cost warning', () => {
const warnings = evaluateConditions({ total_cost_usd: 12 });
const cost = warnings.find(w => w.type === 'cost');
assert.ok(cost, 'Expected a cost warning');
assert.strictEqual(cost.severity, 2);
assert.ok(cost.message.includes('WARNING'), 'Message should contain WARNING');
})
)
passed++;
else failed++;
if (
test('cost $6 triggers NOTICE cost warning', () => {
const warnings = evaluateConditions({ total_cost_usd: 6 });
const cost = warnings.find(w => w.type === 'cost');
assert.ok(cost, 'Expected a cost warning');
assert.strictEqual(cost.severity, 1);
assert.ok(cost.message.includes('NOTICE'), 'Message should contain NOTICE');
})
)
passed++;
else failed++;
if (
test('cost $2 triggers no cost warning', () => {
const warnings = evaluateConditions({ total_cost_usd: 2 });
const cost = warnings.find(w => w.type === 'cost');
assert.strictEqual(cost, undefined);
})
)
passed++;
else failed++;
// evaluateConditions — scope warnings
console.log('\nevaluateConditions (scope):');
if (
test('25 files triggers scope WARNING', () => {
const warnings = evaluateConditions({ files_modified_count: 25 });
const scope = warnings.find(w => w.type === 'scope');
assert.ok(scope, 'Expected a scope warning');
assert.strictEqual(scope.severity, 2);
assert.ok(scope.message.includes('SCOPE'), 'Message should contain SCOPE');
})
)
passed++;
else failed++;
if (
test('10 files triggers no scope warning', () => {
const warnings = evaluateConditions({ files_modified_count: 10 });
const scope = warnings.find(w => w.type === 'scope');
assert.strictEqual(scope, undefined);
})
)
passed++;
else failed++;
// detectLoop tests
console.log('\ndetectLoop:');
if (
test('3 identical entries returns detected true', () => {
const entries = [
{ tool: 'Bash', hash: 'aabbccdd' },
{ tool: 'Bash', hash: 'aabbccdd' },
{ tool: 'Bash', hash: 'aabbccdd' }
];
const result = detectLoop(entries);
assert.strictEqual(result.detected, true);
assert.strictEqual(result.tool, 'Bash');
assert.ok(result.count >= 3);
})
)
passed++;
else failed++;
if (
test('all different entries returns detected false', () => {
const entries = [
{ tool: 'Bash', hash: '11111111' },
{ tool: 'Edit', hash: '22222222' },
{ tool: 'Write', hash: '33333333' }
];
const result = detectLoop(entries);
assert.strictEqual(result.detected, false);
})
)
passed++;
else failed++;
if (
test('empty array returns detected false', () => {
const result = detectLoop([]);
assert.strictEqual(result.detected, false);
})
)
passed++;
else failed++;
// severityLabel tests
console.log('\nseverityLabel:');
if (
test('severity 3 returns critical', () => {
assert.strictEqual(severityLabel(3), 'critical');
})
)
passed++;
else failed++;
if (
test('severity 2 returns warning', () => {
assert.strictEqual(severityLabel(2), 'warning');
})
)
passed++;
else failed++;
if (
test('severity 1 returns notice', () => {
assert.strictEqual(severityLabel(1), 'notice');
})
)
passed++;
else failed++;
// run tests
console.log('\nrun:');
if (
test('empty input returns input unchanged', () => {
const result = run('');
assert.strictEqual(result, '');
})
)
passed++;
else failed++;
if (
test('input without session_id returns input unchanged', () => {
const input = JSON.stringify({ tool_name: 'Bash' });
const result = run(input);
assert.strictEqual(result, input);
})
)
passed++;
else failed++;
// Summary
console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
return { passed, failed };
}
const { failed } = runTests();
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,166 @@
/**
* Tests for scripts/hooks/ecc-metrics-bridge.js
*
* Run with: node tests/hooks/ecc-metrics-bridge.test.js
*/
const assert = require('assert');
const { run, hashToolCall, extractFilePaths, readSessionCost } = require('../../scripts/hooks/ecc-metrics-bridge');
// Test helper
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (err) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing ecc-metrics-bridge.js ===\n');
let passed = 0;
let failed = 0;
// hashToolCall tests
console.log('hashToolCall:');
if (
test('returns 8-char hex string', () => {
const hash = hashToolCall('Bash', { command: 'ls' });
assert.strictEqual(hash.length, 8);
assert.ok(/^[0-9a-f]{8}$/.test(hash), `Expected hex, got: ${hash}`);
})
)
passed++;
else failed++;
if (
test('different Bash commands produce different hashes', () => {
const h1 = hashToolCall('Bash', { command: 'ls' });
const h2 = hashToolCall('Bash', { command: 'pwd' });
assert.notStrictEqual(h1, h2);
})
)
passed++;
else failed++;
if (
test('different Edit file_paths produce different hashes', () => {
const h1 = hashToolCall('Edit', { file_path: 'a.js' });
const h2 = hashToolCall('Edit', { file_path: 'b.js' });
assert.notStrictEqual(h1, h2);
})
)
passed++;
else failed++;
if (
test('same inputs produce same hash (deterministic)', () => {
const h1 = hashToolCall('Write', { file_path: 'x.txt' });
const h2 = hashToolCall('Write', { file_path: 'x.txt' });
assert.strictEqual(h1, h2);
})
)
passed++;
else failed++;
// extractFilePaths tests
console.log('\nextractFilePaths:');
if (
test('Edit with file_path returns [file_path]', () => {
const paths = extractFilePaths('Edit', { file_path: 'a.js' });
assert.deepStrictEqual(paths, ['a.js']);
})
)
passed++;
else failed++;
if (
test('MultiEdit with edits array returns all file_paths', () => {
const paths = extractFilePaths('MultiEdit', {
edits: [{ file_path: 'a.js' }, { file_path: 'b.js' }]
});
assert.deepStrictEqual(paths, ['a.js', 'b.js']);
})
)
passed++;
else failed++;
if (
test('Bash with command returns empty array', () => {
const paths = extractFilePaths('Bash', { command: 'ls' });
assert.deepStrictEqual(paths, []);
})
)
passed++;
else failed++;
if (
test('null toolInput returns empty array', () => {
const paths = extractFilePaths('Edit', null);
assert.deepStrictEqual(paths, []);
})
)
passed++;
else failed++;
// readSessionCost tests
console.log('\nreadSessionCost:');
if (
test('nonexistent session returns object with numeric fields', () => {
const result = readSessionCost('nonexistent-session-cost-test-xyz-999');
assert.strictEqual(typeof result.totalCost, 'number');
assert.strictEqual(typeof result.totalIn, 'number');
assert.strictEqual(typeof result.totalOut, 'number');
assert.ok(result.totalCost >= 0, 'totalCost should be non-negative');
})
)
passed++;
else failed++;
// run tests
console.log('\nrun:');
if (
test('empty input returns empty input without crashing', () => {
const result = run('');
assert.strictEqual(result, '');
})
)
passed++;
else failed++;
if (
test('whitespace-only input returns input unchanged', () => {
const result = run(' ');
assert.strictEqual(result, ' ');
})
)
passed++;
else failed++;
if (
test('input without session_id returns input unchanged', () => {
const input = JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'ls' } });
const result = run(input);
assert.strictEqual(result, input);
})
)
passed++;
else failed++;
// Summary
console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
return { passed, failed };
}
const { failed } = runTests();
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,180 @@
/**
* Tests for scripts/hooks/ecc-statusline.js
*
* Run with: node tests/hooks/ecc-statusline.test.js
*/
const assert = require('assert');
const { formatDuration, buildContextBar, readCurrentTask } = require('../../scripts/hooks/ecc-statusline');
// Test helper
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (err) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing ecc-statusline.js ===\n');
let passed = 0;
let failed = 0;
// formatDuration tests
console.log('formatDuration:');
if (
test('null returns "?"', () => {
assert.strictEqual(formatDuration(null), '?');
})
)
passed++;
else failed++;
if (
test('undefined returns "?"', () => {
assert.strictEqual(formatDuration(undefined), '?');
})
)
passed++;
else failed++;
if (
test('timestamp 30 seconds ago ends with "s"', () => {
const ts = new Date(Date.now() - 30 * 1000).toISOString();
const result = formatDuration(ts);
assert.ok(result.endsWith('s'), `Expected ending in "s", got: ${result}`);
})
)
passed++;
else failed++;
if (
test('timestamp 5 minutes ago ends with "m"', () => {
const ts = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const result = formatDuration(ts);
assert.ok(result.endsWith('m'), `Expected ending in "m", got: ${result}`);
})
)
passed++;
else failed++;
if (
test('timestamp 90 minutes ago contains "h"', () => {
const ts = new Date(Date.now() - 90 * 60 * 1000).toISOString();
const result = formatDuration(ts);
assert.ok(result.includes('h'), `Expected "h" in result, got: ${result}`);
})
)
passed++;
else failed++;
if (
test('future timestamp returns "?"', () => {
const ts = new Date(Date.now() + 60 * 1000).toISOString();
const result = formatDuration(ts);
assert.strictEqual(result, '?');
})
)
passed++;
else failed++;
// buildContextBar tests
console.log('\nbuildContextBar:');
if (
test('null returns empty string', () => {
assert.strictEqual(buildContextBar(null), '');
})
)
passed++;
else failed++;
if (
test('undefined returns empty string', () => {
assert.strictEqual(buildContextBar(undefined), '');
})
)
passed++;
else failed++;
if (
test('80% remaining contains green ANSI code', () => {
const bar = buildContextBar(80);
assert.ok(bar.includes('\x1b[32m'), `Expected green ANSI in: ${JSON.stringify(bar)}`);
})
)
passed++;
else failed++;
if (
test('50% remaining contains yellow ANSI code', () => {
const bar = buildContextBar(50);
assert.ok(bar.includes('\x1b[33m'), `Expected yellow ANSI in: ${JSON.stringify(bar)}`);
})
)
passed++;
else failed++;
if (
test('20% remaining contains red blink ANSI code', () => {
const bar = buildContextBar(20);
assert.ok(bar.includes('\x1b[5;31m'), `Expected red blink ANSI in: ${JSON.stringify(bar)}`);
})
)
passed++;
else failed++;
if (
test('context bar contains block characters', () => {
const bar = buildContextBar(60);
assert.ok(bar.includes('\u2588') || bar.includes('\u2591'), 'Expected block characters in bar');
})
)
passed++;
else failed++;
if (
test('context bar contains percentage', () => {
const bar = buildContextBar(70);
assert.ok(bar.includes('%'), 'Expected percentage in bar');
})
)
passed++;
else failed++;
// readCurrentTask tests
console.log('\nreadCurrentTask:');
if (
test('nonexistent session returns empty string', () => {
const result = readCurrentTask('nonexistent-session-xyz-999');
assert.strictEqual(result, '');
})
)
passed++;
else failed++;
if (
test('empty string session returns empty string', () => {
const result = readCurrentTask('');
assert.strictEqual(result, '');
})
)
passed++;
else failed++;
// Summary
console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
return { passed, failed };
}
const { failed } = runTests();
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,578 @@
/**
* Tests for scripts/hooks/gateguard-fact-force.js via run-with-flags.js
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js');
const externalStateDir = process.env.GATEGUARD_STATE_DIR;
const tmpRoot = process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp';
const baseStateDir = externalStateDir || tmpRoot;
const stateDir = fs.mkdtempSync(path.join(baseStateDir, 'gateguard-test-'));
// Use a fixed session ID so test process and spawned hook process share the same state file
const TEST_SESSION_ID = 'gateguard-test-session';
const stateFile = path.join(stateDir, `state-${TEST_SESSION_ID}.json`);
const READ_HEARTBEAT_MS = 60 * 1000;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function clearState() {
try {
if (fs.existsSync(stateDir)) {
fs.rmSync(stateDir, { recursive: true, force: true });
}
fs.mkdirSync(stateDir, { recursive: true });
} catch (err) {
console.error(` [clearState] failed to remove state files in ${stateDir}: ${err.message}`);
}
}
function writeExpiredState() {
try {
fs.mkdirSync(stateDir, { recursive: true });
const expired = {
checked: ['some_file.js', '__bash_session__'],
last_active: Date.now() - (31 * 60 * 1000) // 31 minutes ago
};
fs.writeFileSync(stateFile, JSON.stringify(expired), 'utf8');
} catch (_) { /* ignore */ }
}
function writeState(state) {
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(stateFile, JSON.stringify(state), 'utf8');
}
function runHook(input, env = {}) {
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
const result = spawnSync('node', [
runner,
'pre:edit-write:gateguard-fact-force',
'scripts/hooks/gateguard-fact-force.js',
'standard,strict'
], {
input: rawInput,
encoding: 'utf8',
env: {
...process.env,
ECC_HOOK_PROFILE: 'standard',
GATEGUARD_STATE_DIR: stateDir,
CLAUDE_SESSION_ID: TEST_SESSION_ID,
...env
},
timeout: 15000,
stdio: ['pipe', 'pipe', 'pipe']
});
return {
code: Number.isInteger(result.status) ? result.status : 1,
stdout: result.stdout || '',
stderr: result.stderr || ''
};
}
function runBashHook(input, env = {}) {
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
const result = spawnSync('node', [
runner,
'pre:bash:gateguard-fact-force',
'scripts/hooks/gateguard-fact-force.js',
'standard,strict'
], {
input: rawInput,
encoding: 'utf8',
env: {
...process.env,
ECC_HOOK_PROFILE: 'standard',
GATEGUARD_STATE_DIR: stateDir,
CLAUDE_SESSION_ID: TEST_SESSION_ID,
...env
},
timeout: 15000,
stdio: ['pipe', 'pipe', 'pipe']
});
return {
code: Number.isInteger(result.status) ? result.status : 1,
stdout: result.stdout || '',
stderr: result.stderr || ''
};
}
function parseOutput(stdout) {
try {
return JSON.parse(stdout);
} catch (_) {
return null;
}
}
function runTests() {
console.log('\n=== Testing gateguard-fact-force ===\n');
let passed = 0;
let failed = 0;
// --- Test 1: denies first Edit per file ---
clearState();
if (test('denies first Edit per file with fact-forcing message', () => {
const input = {
tool_name: 'Edit',
tool_input: { file_path: '/src/app.js', old_string: 'foo', new_string: 'bar' }
};
const result = runHook(input);
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce JSON output');
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Fact-Forcing Gate'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('import/require'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('/src/app.js'));
})) passed++; else failed++;
// --- Test 2: allows second Edit on same file ---
if (test('allows second Edit on same file (gate already passed)', () => {
const input = {
tool_name: 'Edit',
tool_input: { file_path: '/src/app.js', old_string: 'foo', new_string: 'bar' }
};
const result = runHook(input);
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
// When allowed, the hook passes through the raw input (no hookSpecificOutput)
// OR if hookSpecificOutput exists, it must not be deny
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'should not deny second edit on same file');
} else {
// Pass-through: output matches original input (allow)
assert.strictEqual(output.tool_name, 'Edit', 'pass-through should preserve input');
}
})) passed++; else failed++;
// --- Test 3: denies first Write per file ---
clearState();
if (test('denies first Write per file with fact-forcing message', () => {
const input = {
tool_name: 'Write',
tool_input: { file_path: '/src/new-file.js', content: 'console.log("hello")' }
};
const result = runHook(input);
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce JSON output');
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('creating'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('call this new file'));
})) passed++; else failed++;
// --- Test 4: denies destructive Bash, allows retry ---
clearState();
if (test('denies destructive Bash commands, allows retry after facts presented', () => {
const input = {
tool_name: 'Bash',
tool_input: { command: 'rm -rf /important/data' }
};
// First call: should deny
const result1 = runBashHook(input);
assert.strictEqual(result1.code, 0, 'first call exit code should be 0');
const output1 = parseOutput(result1.stdout);
assert.ok(output1, 'first call should produce JSON output');
assert.strictEqual(output1.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output1.hookSpecificOutput.permissionDecisionReason.includes('Destructive'));
assert.ok(output1.hookSpecificOutput.permissionDecisionReason.includes('rollback'));
// Second call (retry after facts presented): should allow
const result2 = runBashHook(input);
assert.strictEqual(result2.code, 0, 'second call exit code should be 0');
const output2 = parseOutput(result2.stdout);
assert.ok(output2, 'second call should produce valid JSON output');
if (output2.hookSpecificOutput) {
assert.notStrictEqual(output2.hookSpecificOutput.permissionDecision, 'deny',
'should not deny destructive bash retry after facts presented');
} else {
assert.strictEqual(output2.tool_name, 'Bash', 'pass-through should preserve input');
}
})) passed++; else failed++;
// --- Test 5: denies first routine Bash, allows second ---
clearState();
if (test('denies first routine Bash, allows second', () => {
const input = {
tool_name: 'Bash',
tool_input: { command: 'ls -la' }
};
// First call: should deny
const result1 = runBashHook(input);
assert.strictEqual(result1.code, 0, 'first call exit code should be 0');
const output1 = parseOutput(result1.stdout);
assert.ok(output1, 'first call should produce JSON output');
assert.strictEqual(output1.hookSpecificOutput.permissionDecision, 'deny');
// Second call: should allow
const result2 = runBashHook(input);
assert.strictEqual(result2.code, 0, 'second call exit code should be 0');
const output2 = parseOutput(result2.stdout);
assert.ok(output2, 'second call should produce valid JSON output');
if (output2.hookSpecificOutput) {
assert.notStrictEqual(output2.hookSpecificOutput.permissionDecision, 'deny',
'should not deny second routine bash');
} else {
assert.strictEqual(output2.tool_name, 'Bash', 'pass-through should preserve input');
}
})) passed++; else failed++;
// --- Test 6: session state resets after timeout ---
if (test('session state resets after 30-minute timeout', () => {
writeExpiredState();
const input = {
tool_name: 'Edit',
tool_input: { file_path: 'some_file.js', old_string: 'a', new_string: 'b' }
};
const result = runHook(input);
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce JSON output after expired state');
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'should deny again after session timeout (state was reset)');
})) passed++; else failed++;
// --- Test 7: allows unknown tool names ---
clearState();
if (test('allows unknown tool names through', () => {
const input = {
tool_name: 'Read',
tool_input: { file_path: '/src/app.js' }
};
const result = runHook(input);
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'should not deny unknown tool');
} else {
assert.strictEqual(output.tool_name, 'Read', 'pass-through should preserve input');
}
})) passed++; else failed++;
// --- Test 8: sanitizes file paths with newlines ---
clearState();
if (test('sanitizes file paths containing newlines', () => {
const input = {
tool_name: 'Edit',
tool_input: { file_path: '/src/app.js\ninjected content', old_string: 'a', new_string: 'b' }
};
const result = runHook(input);
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce JSON output');
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
const reason = output.hookSpecificOutput.permissionDecisionReason;
// The file path portion of the reason must not contain any raw newlines
// (sanitizePath replaces \n and \r with spaces)
const pathLine = reason.split('\n').find(l => l.includes('/src/app.js'));
assert.ok(pathLine, 'reason should mention the file path');
assert.ok(!pathLine.includes('\n'), 'file path line must not contain raw newlines');
assert.ok(!reason.includes('/src/app.js\n'), 'newline after file path should be sanitized');
assert.ok(!reason.includes('\ninjected'), 'injected content must not appear on its own line');
})) passed++; else failed++;
// --- Test 9: respects ECC_DISABLED_HOOKS ---
clearState();
if (test('respects ECC_DISABLED_HOOKS (skips when disabled)', () => {
const input = {
tool_name: 'Edit',
tool_input: { file_path: '/src/disabled.js', old_string: 'a', new_string: 'b' }
};
const result = runHook(input, {
ECC_DISABLED_HOOKS: 'pre:edit-write:gateguard-fact-force'
});
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'should not deny when hook is disabled');
} else {
// When disabled, hook passes through raw input
assert.strictEqual(output.tool_name, 'Edit', 'pass-through should preserve input');
}
})) passed++; else failed++;
// --- Test 10: MultiEdit gates first unchecked file ---
clearState();
if (test('denies first MultiEdit with unchecked file', () => {
const input = {
tool_name: 'MultiEdit',
tool_input: {
edits: [
{ file_path: '/src/multi-a.js', old_string: 'a', new_string: 'b' },
{ file_path: '/src/multi-b.js', old_string: 'c', new_string: 'd' }
]
}
};
const result = runHook(input);
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce JSON output');
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Fact-Forcing Gate'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('/src/multi-a.js'));
})) passed++; else failed++;
// --- Test 11: MultiEdit allows after all files gated ---
if (test('allows MultiEdit after all files gated', () => {
// multi-a.js was gated in test 10; gate multi-b.js
const input2 = {
tool_name: 'MultiEdit',
tool_input: { edits: [{ file_path: '/src/multi-b.js', old_string: 'c', new_string: 'd' }] }
};
runHook(input2); // gates multi-b.js
// Now both files are gated — retry should allow
const input3 = {
tool_name: 'MultiEdit',
tool_input: {
edits: [
{ file_path: '/src/multi-a.js', old_string: 'a', new_string: 'b' },
{ file_path: '/src/multi-b.js', old_string: 'c', new_string: 'd' }
]
}
};
const result3 = runHook(input3);
const output3 = parseOutput(result3.stdout);
assert.ok(output3, 'should produce valid JSON');
if (output3.hookSpecificOutput) {
assert.notStrictEqual(output3.hookSpecificOutput.permissionDecision, 'deny',
'should allow MultiEdit after all files gated');
}
})) passed++; else failed++;
// --- Test 12: hot-path reads do not rewrite state within heartbeat ---
clearState();
if (test('does not rewrite state on hot-path reads within heartbeat window', () => {
const recentlyActive = Date.now() - (READ_HEARTBEAT_MS - 10 * 1000);
writeState({
checked: ['/src/keep-alive.js'],
last_active: recentlyActive
});
const beforeStat = fs.statSync(stateFile);
const before = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
assert.strictEqual(before.last_active, recentlyActive, 'seed state should use the expected timestamp');
const result = runHook({
tool_name: 'Edit',
tool_input: { file_path: '/src/keep-alive.js', old_string: 'a', new_string: 'b' }
});
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'already-checked file should still be allowed');
}
const afterStat = fs.statSync(stateFile);
const after = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
assert.strictEqual(after.last_active, recentlyActive, 'read should not touch last_active within heartbeat');
assert.strictEqual(afterStat.mtimeMs, beforeStat.mtimeMs, 'read should not rewrite the state file within heartbeat');
})) passed++; else failed++;
// --- Test 13: reads refresh stale active state after heartbeat ---
clearState();
if (test('refreshes last_active after heartbeat elapses', () => {
const staleButActive = Date.now() - (READ_HEARTBEAT_MS + 5 * 1000);
writeState({
checked: ['/src/keep-alive.js'],
last_active: staleButActive
});
const result = runHook({
tool_name: 'Edit',
tool_input: { file_path: '/src/keep-alive.js', old_string: 'a', new_string: 'b' }
});
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'already-checked file should still be allowed');
}
const after = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
assert.ok(after.last_active > staleButActive, 'read should refresh last_active after heartbeat');
})) passed++; else failed++;
// --- Test 14: pruning preserves routine bash gate marker ---
clearState();
if (test('preserves __bash_session__ when pruning oversized state', () => {
const checked = ['__bash_session__'];
for (let i = 0; i < 80; i++) checked.push(`__destructive__${i}`);
for (let i = 0; i < 700; i++) checked.push(`/src/file-${i}.js`);
writeState({ checked, last_active: Date.now() });
runHook({
tool_name: 'Edit',
tool_input: { file_path: '/src/newly-gated.js', old_string: 'a', new_string: 'b' }
});
const result = runBashHook({
tool_name: 'Bash',
tool_input: { command: 'pwd' }
});
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'routine bash marker should survive pruning');
}
const persisted = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
assert.ok(persisted.checked.includes('__bash_session__'), 'pruned state should retain __bash_session__');
assert.ok(persisted.checked.length <= 500, 'pruned state should still honor the checked-entry cap');
})) passed++; else failed++;
// --- Test 15: raw input session IDs provide stable retry state without env vars ---
clearState();
if (test('uses raw input session_id when hook env vars are missing', () => {
const input = {
session_id: 'raw-session-1234',
tool_name: 'Bash',
tool_input: { command: 'ls -la' }
};
const first = runBashHook(input, {
CLAUDE_SESSION_ID: '',
ECC_SESSION_ID: '',
});
const firstOutput = parseOutput(first.stdout);
assert.strictEqual(firstOutput.hookSpecificOutput.permissionDecision, 'deny');
const second = runBashHook(input, {
CLAUDE_SESSION_ID: '',
ECC_SESSION_ID: '',
});
const secondOutput = parseOutput(second.stdout);
if (secondOutput.hookSpecificOutput) {
assert.notStrictEqual(secondOutput.hookSpecificOutput.permissionDecision, 'deny',
'retry should be allowed when raw session_id is stable');
} else {
assert.strictEqual(secondOutput.tool_name, 'Bash');
}
})) passed++; else failed++;
// --- Test 16: allows Claude settings edits so the hook can be disabled safely ---
clearState();
if (test('allows edits to .claude/settings.json without gating', () => {
const input = {
tool_name: 'Edit',
tool_input: { file_path: '/workspace/app/.claude/settings.json', old_string: '{}', new_string: '{"hooks":[]}' }
};
const result = runHook(input);
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'settings edits must not be blocked by gateguard');
} else {
assert.strictEqual(output.tool_name, 'Edit');
}
})) passed++; else failed++;
// --- Test 17: allows read-only git introspection without first-bash gating ---
clearState();
if (test('allows read-only git status without first-bash gating', () => {
const input = {
tool_name: 'Bash',
tool_input: { command: 'git status --short' }
};
const result = runBashHook(input);
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'read-only git introspection should not be blocked');
} else {
assert.strictEqual(output.tool_name, 'Bash');
}
})) passed++; else failed++;
// --- Test 18: rejects mutating git commands that only share a prefix ---
clearState();
if (test('does not treat mutating git commands as read-only introspection', () => {
const input = {
tool_name: 'Bash',
tool_input: { command: 'git status && rm -rf /tmp/demo' }
};
const result = runBashHook(input);
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('current instruction'));
})) passed++; else failed++;
// --- Test 19: long raw session IDs hash instead of collapsing to project fallback ---
clearState();
if (test('uses a stable hash for long raw session ids', () => {
const longSessionId = `session-${'x'.repeat(120)}`;
const input = {
session_id: longSessionId,
tool_name: 'Bash',
tool_input: { command: 'ls -la' }
};
const first = runBashHook(input, {
CLAUDE_SESSION_ID: '',
ECC_SESSION_ID: '',
});
const firstOutput = parseOutput(first.stdout);
assert.strictEqual(firstOutput.hookSpecificOutput.permissionDecision, 'deny');
const stateFiles = fs.readdirSync(stateDir).filter(entry => entry.startsWith('state-') && entry.endsWith('.json'));
assert.strictEqual(stateFiles.length, 1, 'long raw session id should still produce a dedicated state file');
assert.ok(/state-sid-[a-f0-9]{24}\.json$/.test(stateFiles[0]), 'long raw session ids should hash to a bounded sid-* key');
const second = runBashHook(input, {
CLAUDE_SESSION_ID: '',
ECC_SESSION_ID: '',
});
const secondOutput = parseOutput(second.stdout);
if (secondOutput.hookSpecificOutput) {
assert.notStrictEqual(secondOutput.hookSpecificOutput.permissionDecision, 'deny',
'retry should be allowed when long raw session_id is stable');
} else {
assert.strictEqual(secondOutput.tool_name, 'Bash');
}
})) passed++; else failed++;
// Cleanup only the temp directory created by this test file.
try {
if (fs.existsSync(stateDir)) {
fs.rmSync(stateDir, { recursive: true, force: true });
}
} catch (err) {
console.error(` [cleanup] failed to remove ${stateDir}: ${err.message}`);
}
console.log(`\n ${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View File

@@ -1888,6 +1888,33 @@ async function runTests() {
passed++;
else failed++;
if (
test('hooks.json consolidates Bash hooks into one pre and one post dispatcher', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
const preBash = hooks.hooks.PreToolUse.filter(entry => entry.matcher === 'Bash');
const postBash = hooks.hooks.PostToolUse.filter(entry => entry.matcher === 'Bash');
assert.strictEqual(preBash.length, 1, 'Should have exactly one PreToolUse Bash dispatcher');
assert.strictEqual(postBash.length, 1, 'Should have exactly one PostToolUse Bash dispatcher');
assert.strictEqual(preBash[0].id, 'pre:bash:dispatcher');
assert.strictEqual(postBash[0].id, 'post:bash:dispatcher');
const preCommand = Array.isArray(preBash[0].hooks[0].command)
? preBash[0].hooks[0].command.join(' ')
: preBash[0].hooks[0].command;
const postCommand = Array.isArray(postBash[0].hooks[0].command)
? postBash[0].hooks[0].command.join(' ')
: postBash[0].hooks[0].command;
assert.ok(preCommand.includes('pre-bash-dispatcher.js'), 'PreToolUse Bash hook should use the pre dispatcher');
assert.ok(postCommand.includes('post-bash-dispatcher.js'), 'PostToolUse Bash hook should use the post dispatcher');
})
)
passed++;
else failed++;
if (
test('SessionEnd marker hook is async and cleanup-safe', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
@@ -1903,6 +1930,27 @@ async function runTests() {
passed++;
else failed++;
if (
test('all hook commands use string form for Claude Code schema compatibility', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
for (const [eventName, hookArray] of Object.entries(hooks.hooks)) {
for (const entry of hookArray) {
for (const hook of entry.hooks) {
assert.strictEqual(
typeof hook.command,
'string',
`${eventName}/${entry.id || entry.matcher || 'hook'} should use string command form`,
);
}
}
}
})
)
passed++;
else failed++;
if (
test('all hook commands use node or approved shell wrappers', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
@@ -1912,13 +1960,14 @@ async function runTests() {
for (const entry of hookArray) {
for (const hook of entry.hooks) {
if (hook.type === 'command') {
const isNode = hook.command.startsWith('node');
const isNpx = hook.command.startsWith('npx ');
const isSkillScript = hook.command.includes('/skills/') && (/^(bash|sh)\s/.test(hook.command) || hook.command.startsWith('${CLAUDE_PLUGIN_ROOT}/skills/'));
const isHookShellWrapper = /^(bash|sh)\s+["']?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/run-with-flags-shell\.sh/.test(hook.command);
const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command;
const commandStart = Array.isArray(hook.command) ? hook.command[0] : hook.command;
const isNode = commandStart === 'node' || (typeof commandStart === 'string' && commandStart.startsWith('node'));
const isNpx = commandStart === 'npx' || (typeof commandStart === 'string' && commandStart.startsWith('npx '));
const isSkillScript = commandText.includes('/skills/') && (/^(bash|sh)\s/.test(commandText) || commandText.includes('/skills/'));
assert.ok(
isNode || isNpx || isSkillScript || isHookShellWrapper,
`Hook command should use node or approved shell wrapper: ${hook.command.substring(0, 100)}...`
isNode || isNpx || isSkillScript,
`Hook command should use node or approved shell wrapper: ${commandText.substring(0, 100)}...`
);
}
}
@@ -1940,16 +1989,16 @@ async function runTests() {
const sessionStartHook = hooks.hooks.SessionStart?.[0]?.hooks?.[0];
assert.ok(sessionStartHook, 'Should define a SessionStart hook');
// The bootstrap was extracted to a standalone file to avoid shell history
// expansion of `!` characters that caused startup hook errors when the
// logic was embedded as an inline `node -e "..."` string.
const commandText = sessionStartHook.command;
assert.strictEqual(typeof sessionStartHook.command, 'string', 'SessionStart should use string command form for Claude Code compatibility');
assert.ok(
sessionStartHook.command.includes('session-start-bootstrap.js'),
commandText.includes('session-start-bootstrap.js'),
'SessionStart should delegate to the extracted bootstrap script'
);
assert.ok(sessionStartHook.command.includes('CLAUDE_PLUGIN_ROOT'), 'SessionStart should use CLAUDE_PLUGIN_ROOT');
assert.ok(!sessionStartHook.command.includes('find '), 'Should not scan arbitrary plugin paths with find');
assert.ok(!sessionStartHook.command.includes('head -n 1'), 'Should not pick the first matching plugin path');
assert.ok(commandText.includes('CLAUDE_PLUGIN_ROOT'), 'SessionStart should use CLAUDE_PLUGIN_ROOT');
assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), 'SessionStart should not depend on raw shell placeholder expansion');
assert.ok(!commandText.includes('find '), 'Should not scan arbitrary plugin paths with find');
assert.ok(!commandText.includes('head -n 1'), 'Should not pick the first matching plugin path');
// Verify the bootstrap script itself contains the expected logic
const bootstrapPath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'session-start-bootstrap.js');
@@ -1971,29 +2020,41 @@ async function runTests() {
const sessionEndHooks = (hooks.hooks.SessionEnd || []).flatMap(entry => entry.hooks || []);
for (const hook of [...stopHooks, ...sessionEndHooks]) {
assert.ok(hook.command.startsWith('node -e "'), 'Lifecycle hook should use inline node resolver');
assert.ok(hook.command.includes('run-with-flags.js'), 'Lifecycle hook should resolve the runner script');
assert.ok(hook.command.includes('CLAUDE_PLUGIN_ROOT'), 'Lifecycle hook should consult CLAUDE_PLUGIN_ROOT');
assert.ok(hook.command.includes('plugins'), 'Lifecycle hook should probe known plugin roots');
assert.ok(!hook.command.includes('find '), 'Lifecycle hook should not scan arbitrary plugin paths with find');
assert.ok(!hook.command.includes('head -n 1'), 'Lifecycle hook should not pick the first matching plugin path');
const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command;
assert.ok(
(Array.isArray(hook.command) && hook.command[0] === 'node' && hook.command[1] === '-e') ||
(typeof hook.command === 'string' && hook.command.startsWith('node -e "')),
'Lifecycle hook should use inline node resolver'
);
assert.ok(commandText.includes('run-with-flags.js'), 'Lifecycle hook should resolve the runner script');
assert.ok(commandText.includes('CLAUDE_PLUGIN_ROOT'), 'Lifecycle hook should consult CLAUDE_PLUGIN_ROOT');
assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), 'Lifecycle hook should not depend on raw shell placeholder expansion');
assert.ok(commandText.includes('plugins'), 'Lifecycle hook should probe known plugin roots');
assert.ok(!commandText.includes('find '), 'Lifecycle hook should not scan arbitrary plugin paths with find');
assert.ok(!commandText.includes('head -n 1'), 'Lifecycle hook should not pick the first matching plugin path');
}
})
)
passed++;
else failed++;
if (
test('script references use CLAUDE_PLUGIN_ROOT variable or a safe inline resolver', () => {
test('script references use the safe inline resolver or plugin bootstrap', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
const checkHooks = hookArray => {
for (const entry of hookArray) {
for (const hook of entry.hooks) {
if (hook.type === 'command' && hook.command.includes('scripts/hooks/')) {
const usesInlineResolver = hook.command.startsWith('node -e') && hook.command.includes('run-with-flags.js');
const hasPluginRoot = hook.command.includes('${CLAUDE_PLUGIN_ROOT}') || usesInlineResolver;
assert.ok(hasPluginRoot, `Script paths should use CLAUDE_PLUGIN_ROOT: ${hook.command.substring(0, 80)}...`);
const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command;
const commandStart = Array.isArray(hook.command) ? `${hook.command[0]} ${hook.command[1] || ''}`.trim() : hook.command;
if (hook.type === 'command' && commandText.includes('scripts/hooks/')) {
const usesInlineResolver = commandStart.startsWith('node -e') && commandText.includes('run-with-flags.js');
const usesPluginBootstrap = commandStart.startsWith('node -e') && commandText.includes('plugin-hook-bootstrap.js');
assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), `Script paths should not depend on raw shell placeholder expansion: ${commandText.substring(0, 80)}...`);
assert.ok(
usesInlineResolver || usesPluginBootstrap,
`Script paths should use the inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`
);
}
}
}

View File

@@ -6,6 +6,8 @@
const assert = require('assert');
const fs = require('fs');
const http = require('http');
const https = require('https');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
@@ -109,6 +111,39 @@ function waitForFile(filePath, timeoutMs = 5000) {
}
throw new Error(`Timed out waiting for ${filePath}`);
}
function waitForHttpReady(urlString, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
const { protocol } = new URL(urlString);
const client = protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const attempt = () => {
const req = client.request(urlString, { method: 'GET' }, res => {
res.resume();
resolve();
});
req.setTimeout(250, () => {
req.destroy(new Error('timeout'));
});
req.on('error', error => {
if (Date.now() >= deadline) {
reject(new Error(`Timed out waiting for ${urlString}: ${error.message}`));
return;
}
setTimeout(attempt, 25);
});
req.end();
};
attempt();
});
}
async function runTests() {
console.log('\n=== Testing mcp-health-check.js ===\n');
@@ -329,6 +364,7 @@ async function runTests() {
try {
const port = waitForFile(portFile).trim();
await waitForHttpReady(`http://127.0.0.1:${port}/mcp`);
writeConfig(configPath, {
mcpServers: {
@@ -391,6 +427,7 @@ async function runTests() {
try {
const port = waitForFile(portFile).trim();
await waitForHttpReady(`http://127.0.0.1:${port}/mcp`);
writeConfig(configPath, {
mcpServers: {

View File

@@ -110,24 +110,70 @@ function runHookCommand(command, input = {}, env = {}, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
const isWindows = process.platform === 'win32';
const mergedEnv = { ...process.env, CLAUDE_PLUGIN_ROOT: REPO_ROOT, ...env };
if (Array.isArray(command)) {
const [program, ...args] = command;
const proc = spawn(program, args, { env: mergedEnv, stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
let timer;
proc.stdout.on('data', data => stdout += data);
proc.stderr.on('data', data => stderr += data);
proc.stdin.on('error', (err) => {
if (err.code !== 'EPIPE' && err.code !== 'EOF') {
if (timer) clearTimeout(timer);
reject(err);
}
});
if (input && Object.keys(input).length > 0) {
proc.stdin.write(JSON.stringify(input));
}
proc.stdin.end();
timer = setTimeout(() => {
proc.kill(isWindows ? undefined : 'SIGKILL');
reject(new Error(`Hook command timed out after ${timeoutMs}ms`));
}, timeoutMs);
proc.on('close', code => {
clearTimeout(timer);
resolve({ code, stdout, stderr });
});
proc.on('error', err => {
clearTimeout(timer);
reject(err);
});
return;
}
const resolvedCommand = command.replace(
/\$\{([A-Z_][A-Z0-9_]*)\}/g,
(_, name) => String(mergedEnv[name] || '')
);
const nodeMatch = resolvedCommand.match(/^node\s+"([^"]+)"\s*(.*)$/);
const useDirectNodeSpawn = Boolean(nodeMatch);
const inlineNodeMatch = resolvedCommand.match(/^node -e "((?:[^"\\]|\\.)*)"(?:\s+(.*))?$/s);
const fileNodeMatch = resolvedCommand.match(/^node\s+"([^"]+)"\s*(.*)$/);
const useDirectNodeSpawn = Boolean(inlineNodeMatch || fileNodeMatch);
const shell = isWindows ? 'cmd' : 'bash';
const shellArgs = isWindows ? ['/d', '/s', '/c', resolvedCommand] : ['-lc', resolvedCommand];
const nodeArgs = nodeMatch
? [
nodeMatch[1],
...Array.from(
nodeMatch[2].matchAll(/"([^"]*)"|(\S+)/g),
m => m[1] !== undefined ? m[1] : m[2]
)
]
: [];
const splitArgs = value => Array.from(
String(value || '').matchAll(/"([^"]*)"|(\S+)/g),
m => m[1] !== undefined ? m[1] : m[2]
);
const unescapeInlineJs = value => value
.replace(/\\\\/g, '\\')
.replace(/\\"/g, '"')
.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t');
const nodeArgs = inlineNodeMatch
? ['-e', unescapeInlineJs(inlineNodeMatch[1]), ...splitArgs(inlineNodeMatch[2])]
: fileNodeMatch
? [fileNodeMatch[1], ...splitArgs(fileNodeMatch[2])]
: [];
const proc = useDirectNodeSpawn
? spawn('node', nodeArgs, { env: mergedEnv, stdio: ['pipe', 'pipe', 'pipe'] })
@@ -210,6 +256,14 @@ function getHookCommandByDescription(hooks, lifecycle, descriptionText) {
return hookGroup.hooks[0].command;
}
function getHookCommandById(hooks, lifecycle, hookId) {
const hookGroup = hooks.hooks[lifecycle]?.find(entry => entry.id === hookId);
assert.ok(hookGroup, `Expected ${lifecycle} hook with id "${hookId}"`);
assert.ok(hookGroup.hooks?.[0]?.command, `Expected ${lifecycle} hook command for id "${hookId}"`);
return hookGroup.hooks[0].command;
}
// Test suite
async function runTests() {
console.log('\n=== Hook Integration Tests ===\n');
@@ -294,12 +348,7 @@ async function runTests() {
})) passed++; else failed++;
if (await asyncTest('dev server hook transforms command to tmux session', async () => {
// Test the auto-tmux dev hook — transforms dev commands to run in tmux
const hookCommand = getHookCommandByDescription(
hooks,
'PreToolUse',
'Auto-start dev servers in tmux'
);
const hookCommand = getHookCommandById(hooks, 'PreToolUse', 'pre:bash:dispatcher');
const result = await runHookCommand(hookCommand, {
tool_input: { command: 'npm run dev' }
});
@@ -480,12 +529,7 @@ async function runTests() {
})) passed++; else failed++;
if (await asyncTest('dev server hook transforms yarn dev to tmux session', async () => {
// The auto-tmux dev hook transforms dev commands (yarn dev, npm run dev, etc.)
const hookCommand = getHookCommandByDescription(
hooks,
'PreToolUse',
'Auto-start dev servers in tmux'
);
const hookCommand = getHookCommandById(hooks, 'PreToolUse', 'pre:bash:dispatcher');
const result = await runHookCommand(hookCommand, {
tool_input: { command: 'yarn dev' }
});
@@ -617,14 +661,8 @@ async function runTests() {
})) passed++; else failed++;
if (await asyncTest('PostToolUse PR hook extracts PR URL', async () => {
// Find the PR logging hook
const prHook = hooks.hooks.PostToolUse.find(h =>
h.description && h.description.includes('PR URL')
);
assert.ok(prHook, 'PR hook should exist');
const result = await runHookCommand(prHook.hooks[0].command, {
const hookCommand = getHookCommandById(hooks, 'PostToolUse', 'post:bash:dispatcher');
const result = await runHookCommand(hookCommand, {
tool_input: { command: 'gh pr create --title "Test"' },
tool_output: { output: 'Creating pull request...\nhttps://github.com/owner/repo/pull/123' }
});
@@ -899,16 +937,22 @@ async function runTests() {
assert.ok(asyncHook.hooks[0].timeout > 0, 'Timeout should be positive');
const command = asyncHook.hooks[0].command;
const isNodeInline = command.startsWith('node -e');
const isNodeScript = command.startsWith('node "');
const commandText = Array.isArray(command) ? command.join(' ') : command;
const isNodeInline =
(Array.isArray(command) && command[0] === 'node' && command[1] === '-e') ||
commandText.startsWith('node -e');
const isNodeScript =
(Array.isArray(command) && command[0] === 'node' && typeof command[1] === 'string' && command[1].endsWith('.js')) ||
commandText.startsWith('node "');
const isShellWrapper =
command.startsWith('bash "') ||
command.startsWith('sh "') ||
command.startsWith('bash -lc ') ||
command.startsWith('sh -c ');
(Array.isArray(command) && (command[0] === 'bash' || command[0] === 'sh')) ||
commandText.startsWith('bash "') ||
commandText.startsWith('sh "') ||
commandText.startsWith('bash -lc ') ||
commandText.startsWith('sh -c ');
assert.ok(
isNodeInline || isNodeScript || isShellWrapper,
`Async hook command should be runnable (node -e, node script, or shell wrapper), got: ${command.substring(0, 80)}`
`Async hook command should be runnable (node -e, node script, or shell wrapper), got: ${commandText.substring(0, 80)}`
);
})) passed++; else failed++;
@@ -920,19 +964,28 @@ async function runTests() {
for (const hook of hookDef.hooks) {
assert.ok(hook.command, `Hook in ${hookType} should have command field`);
const isInline = hook.command.startsWith('node -e');
const isFilePath = hook.command.startsWith('node "');
const isNpx = hook.command.startsWith('npx ');
const command = hook.command;
const commandText = Array.isArray(command) ? command.join(' ') : command;
const isInline =
(Array.isArray(command) && command[0] === 'node' && command[1] === '-e') ||
commandText.startsWith('node -e');
const isFilePath =
(Array.isArray(command) && command[0] === 'node' && typeof command[1] === 'string' && command[1].endsWith('.js')) ||
commandText.startsWith('node "');
const isNpx = (Array.isArray(command) && command[0] === 'npx') || commandText.startsWith('npx ');
const isShellWrapper =
hook.command.startsWith('bash "') ||
hook.command.startsWith('sh "') ||
hook.command.startsWith('bash -lc ') ||
hook.command.startsWith('sh -c ');
const isShellScriptPath = hook.command.endsWith('.sh');
(Array.isArray(command) && (command[0] === 'bash' || command[0] === 'sh')) ||
commandText.startsWith('bash "') ||
commandText.startsWith('sh "') ||
commandText.startsWith('bash -lc ') ||
commandText.startsWith('sh -c ');
const isShellScriptPath =
(Array.isArray(command) && typeof command[0] === 'string' && command[0].endsWith('.sh')) ||
commandText.endsWith('.sh');
assert.ok(
isInline || isFilePath || isNpx || isShellWrapper || isShellScriptPath,
`Hook command in ${hookType} should be node -e, node script, npx, or shell wrapper/script, got: ${hook.command.substring(0, 80)}`
`Hook command in ${hookType} should be node -e, node script, npx, or shell wrapper/script, got: ${commandText.substring(0, 80)}`
);
}
}

Some files were not shown because too many files have changed in this diff Show More