From 375d750b4c14369b6c8ffbdcff442d5dd8292276 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 14 May 2026 21:37:28 -0400 Subject: [PATCH] fix: integrate recent hook and docs PRs (#1905) Integrates useful changes from #1882, #1884, #1889, #1893, #1898, #1899, and #1903: - fix rule install docs to preserve language directories - correct Ruby security command examples - harden dev-server hook command-substitution parsing - add Prisma patterns skill and catalog/package surfaces - allow first-time protected config creation while blocking existing configs - read cost metrics from Stop hook transcripts - emit suggest-compact additionalContext on stdout Co-authored-by: Jamkris Co-authored-by: Levi-Evan Co-authored-by: gaurav0107 Co-authored-by: richm-spp Co-authored-by: zomia Co-authored-by: donghyeun02 --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- AGENTS.md | 4 +- README.md | 6 +- README.zh-CN.md | 2 +- docs/ja-JP/README.md | 28 +- docs/ja-JP/skills/configure-ecc/SKILL.md | 12 +- docs/ko-KR/README.md | 12 +- docs/pt-BR/README.md | 10 +- docs/tr/README.md | 2 +- docs/zh-CN/AGENTS.md | 4 +- docs/zh-CN/README.md | 32 +- docs/zh-CN/skills/configure-ecc/SKILL.md | 12 +- manifests/install-modules.json | 3 +- package.json | 1 + rules/ruby/hooks.md | 4 +- rules/ruby/security.md | 4 +- scripts/hooks/config-protection.js | 39 +- scripts/hooks/cost-tracker.js | 136 ++++++- scripts/hooks/pre-bash-dev-server-block.js | 60 ++- scripts/hooks/suggest-compact.js | 20 +- scripts/lib/shell-substitution.js | 246 ++++++++++++ skills/configure-ecc/SKILL.md | 12 +- skills/prisma-patterns/SKILL.md | 371 ++++++++++++++++++ tests/hooks/config-protection.test.js | 297 ++++++++++---- tests/hooks/cost-tracker.test.js | 53 ++- tests/hooks/pre-bash-dev-server-block.test.js | 104 +++++ tests/hooks/suggest-compact.test.js | 60 +++ 28 files changed, 1350 insertions(+), 188 deletions(-) create mode 100644 scripts/lib/shell-substitution.js create mode 100644 skills/prisma-patterns/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 71047464..08db16af 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "ecc", "source": "./", - "description": "The most comprehensive Claude Code plugin — 60 agents, 228 skills, 75 legacy command shims, selective install profiles, and production-ready hooks for TDD, security scanning, code review, and continuous learning", + "description": "The most comprehensive Claude Code plugin — 60 agents, 229 skills, 75 legacy command shims, selective install profiles, and production-ready hooks for TDD, security scanning, code review, and continuous learning", "version": "2.0.0-rc.1", "author": { "name": "Affaan Mustafa", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e98e81ab..ca38f638 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ecc", "version": "2.0.0-rc.1", - "description": "Battle-tested Claude Code plugin for engineering teams — 60 agents, 228 skills, 75 legacy command shims, production-ready hooks, and selective install workflows evolved through continuous real-world use", + "description": "Battle-tested Claude Code plugin for engineering teams — 60 agents, 229 skills, 75 legacy command shims, production-ready hooks, and selective install workflows evolved through continuous real-world use", "author": { "name": "Affaan Mustafa", "url": "https://x.com/affaanmustafa" diff --git a/AGENTS.md b/AGENTS.md index e34717bb..2f2c5fbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — Agent Instructions -This is a **production-ready AI coding plugin** providing 60 specialized agents, 228 skills, 75 commands, and automated hook workflows for software development. +This is a **production-ready AI coding plugin** providing 60 specialized agents, 229 skills, 75 commands, and automated hook workflows for software development. **Version:** 2.0.0-rc.1 @@ -150,7 +150,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat ``` agents/ — 60 specialized subagents -skills/ — 228 workflow skills and domain knowledge +skills/ — 229 workflow skills and domain knowledge commands/ — 75 slash commands hooks/ — Trigger-based automations rules/ — Always-follow guidelines (common + per-language) diff --git a/README.md b/README.md index e731eb3f..663c8fcc 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ If you stacked methods, clean up in this order: /plugin list ecc@ecc ``` -**That's it!** You now have access to 60 agents, 228 skills, and 75 legacy command shims. +**That's it!** You now have access to 60 agents, 229 skills, and 75 legacy command shims. ### Dashboard GUI @@ -1363,7 +1363,7 @@ The configuration is automatically detected from `.opencode/opencode.json`. |---------|-------------|----------|--------| | Agents | PASS: 60 agents | PASS: 12 agents | **Claude Code leads** | | Commands | PASS: 75 commands | PASS: 35 commands | **Claude Code leads** | -| Skills | PASS: 228 skills | PASS: 37 skills | **Claude Code leads** | +| Skills | PASS: 229 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** | @@ -1525,7 +1525,7 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e |---------|------------|------------|-----------|----------|----------------| | **Agents** | 60 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 | N/A | | **Commands** | 75 | Shared | Instruction-based | 35 | 6 prompts | -| **Skills** | 228 | Shared | 10 (native format) | 37 | Via instructions | +| **Skills** | 229 | Shared | 10 (native format) | 37 | Via instructions | | **Hook Events** | 8 types | 15 types | None yet | 11 types | None | | **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks | N/A | | **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions | 1 always-on file | diff --git a/README.zh-CN.md b/README.zh-CN.md index 19dfb2cf..6ff140f9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -160,7 +160,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/" /plugin list ecc@ecc ``` -**完成!** 你现在可以使用 60 个代理、228 个技能和 75 个命令。 +**完成!** 你现在可以使用 60 个代理、229 个技能和 75 个命令。 ### multi-* 命令需要额外配置 diff --git a/docs/ja-JP/README.md b/docs/ja-JP/README.md index 0a99c37e..06c5f8f1 100644 --- a/docs/ja-JP/README.md +++ b/docs/ja-JP/README.md @@ -122,12 +122,12 @@ git clone https://github.com/affaan-m/everything-claude-code.git # 共通ルールをインストール(必須) -cp -r everything-claude-code/rules/common/* ~/.claude/rules/ +cp -r everything-claude-code/rules/common ~/.claude/rules/common # 言語固有ルールをインストール(スタックを選択) -cp -r everything-claude-code/rules/typescript/* ~/.claude/rules/ -cp -r everything-claude-code/rules/python/* ~/.claude/rules/ -cp -r everything-claude-code/rules/golang/* ~/.claude/rules/ +cp -r everything-claude-code/rules/typescript ~/.claude/rules/typescript +cp -r everything-claude-code/rules/python ~/.claude/rules/python +cp -r everything-claude-code/rules/golang ~/.claude/rules/golang ``` ### ステップ3:使用開始 @@ -462,15 +462,15 @@ Duplicate hook file detected: ./hooks/hooks.json is already resolved to a loaded > > # オプション A:ユーザーレベルルール(すべてのプロジェクトに適用) > mkdir -p ~/.claude/rules -> cp -r everything-claude-code/rules/common/* ~/.claude/rules/ -> cp -r everything-claude-code/rules/typescript/* ~/.claude/rules/ # スタックを選択 -> cp -r everything-claude-code/rules/python/* ~/.claude/rules/ -> cp -r everything-claude-code/rules/golang/* ~/.claude/rules/ +> cp -r everything-claude-code/rules/common ~/.claude/rules/common +> cp -r everything-claude-code/rules/typescript ~/.claude/rules/typescript # スタックを選択 +> cp -r everything-claude-code/rules/python ~/.claude/rules/python +> cp -r everything-claude-code/rules/golang ~/.claude/rules/golang > > # オプション B:プロジェクトレベルルール(現在のプロジェクトのみ) > mkdir -p .claude/rules -> cp -r everything-claude-code/rules/common/* .claude/rules/ -> cp -r everything-claude-code/rules/typescript/* .claude/rules/ # スタックを選択 +> cp -r everything-claude-code/rules/common .claude/rules/common +> cp -r everything-claude-code/rules/typescript .claude/rules/typescript # スタックを選択 > ``` --- @@ -487,10 +487,10 @@ git clone https://github.com/affaan-m/everything-claude-code.git cp everything-claude-code/agents/*.md ~/.claude/agents/ # ルール(共通 + 言語固有)をコピー -cp -r everything-claude-code/rules/common/* ~/.claude/rules/ -cp -r everything-claude-code/rules/typescript/* ~/.claude/rules/ # スタックを選択 -cp -r everything-claude-code/rules/python/* ~/.claude/rules/ -cp -r everything-claude-code/rules/golang/* ~/.claude/rules/ +cp -r everything-claude-code/rules/common ~/.claude/rules/common +cp -r everything-claude-code/rules/typescript ~/.claude/rules/typescript # スタックを選択 +cp -r everything-claude-code/rules/python ~/.claude/rules/python +cp -r everything-claude-code/rules/golang ~/.claude/rules/golang # コマンドをコピー cp everything-claude-code/commands/*.md ~/.claude/commands/ diff --git a/docs/ja-JP/skills/configure-ecc/SKILL.md b/docs/ja-JP/skills/configure-ecc/SKILL.md index 9c289eb4..b8b73d5f 100644 --- a/docs/ja-JP/skills/configure-ecc/SKILL.md +++ b/docs/ja-JP/skills/configure-ecc/SKILL.md @@ -169,13 +169,13 @@ Options: インストールを実行: ```bash -# 共通ルール(rules/ にフラットコピー) -cp -r $ECC_ROOT/rules/common/* $TARGET/rules/ +# 共通ルール +cp -r $ECC_ROOT/rules/common $TARGET/rules/common -# 言語固有のルール(rules/ にフラットコピー) -cp -r $ECC_ROOT/rules/typescript/* $TARGET/rules/ # 選択された場合 -cp -r $ECC_ROOT/rules/python/* $TARGET/rules/ # 選択された場合 -cp -r $ECC_ROOT/rules/golang/* $TARGET/rules/ # 選択された場合 +# 言語固有のルール(言語別ディレクトリを保持) +cp -r $ECC_ROOT/rules/typescript $TARGET/rules/typescript # 選択された場合 +cp -r $ECC_ROOT/rules/python $TARGET/rules/python # 選択された場合 +cp -r $ECC_ROOT/rules/golang $TARGET/rules/golang # 選択された場合 ``` **重要**: ユーザーが言語固有のルールを選択したが、共通ルールを選択しなかった場合、警告します: diff --git a/docs/ko-KR/README.md b/docs/ko-KR/README.md index 26341240..254c3fe6 100644 --- a/docs/ko-KR/README.md +++ b/docs/ko-KR/README.md @@ -387,12 +387,12 @@ Claude Code v2.1+는 설치된 플러그인의 `hooks/hooks.json`을 **자동으 > > # 옵션 A: 사용자 레벨 룰 (모든 프로젝트에 적용) > mkdir -p ~/.claude/rules -> cp -r everything-claude-code/rules/common/* ~/.claude/rules/ -> cp -r everything-claude-code/rules/typescript/* ~/.claude/rules/ # 사용하는 스택 선택 +> cp -r everything-claude-code/rules/common ~/.claude/rules/common +> cp -r everything-claude-code/rules/typescript ~/.claude/rules/typescript # 사용하는 스택 선택 > > # 옵션 B: 프로젝트 레벨 룰 (현재 프로젝트에만 적용) > mkdir -p .claude/rules -> cp -r everything-claude-code/rules/common/* .claude/rules/ +> cp -r everything-claude-code/rules/common .claude/rules/common > ``` --- @@ -409,8 +409,8 @@ git clone https://github.com/affaan-m/everything-claude-code.git cp everything-claude-code/agents/*.md ~/.claude/agents/ # 룰 복사 (common + 언어별) -cp -r everything-claude-code/rules/common/* ~/.claude/rules/ -cp -r everything-claude-code/rules/typescript/* ~/.claude/rules/ # 사용하는 스택 선택 +cp -r everything-claude-code/rules/common ~/.claude/rules/common +cp -r everything-claude-code/rules/typescript ~/.claude/rules/typescript # 사용하는 스택 선택 # 커맨드 복사 cp everything-claude-code/commands/*.md ~/.claude/commands/ @@ -573,7 +573,7 @@ MCP 서버가 너무 많으면 컨텍스트를 잡아먹습니다. 각 MCP 도 cp everything-claude-code/agents/*.md ~/.claude/agents/ # 룰만 -cp -r everything-claude-code/rules/common/* ~/.claude/rules/ +cp -r everything-claude-code/rules/common ~/.claude/rules/common ``` 각 컴포넌트는 완전히 독립적입니다. diff --git a/docs/pt-BR/README.md b/docs/pt-BR/README.md index a9af42fb..df685652 100644 --- a/docs/pt-BR/README.md +++ b/docs/pt-BR/README.md @@ -342,12 +342,12 @@ Ou adicione diretamente ao seu `~/.claude/settings.json`: > > # Opção A: Regras no nível do usuário (aplica a todos os projetos) > mkdir -p ~/.claude/rules -> cp -r everything-claude-code/rules/common/* ~/.claude/rules/ -> cp -r everything-claude-code/rules/typescript/* ~/.claude/rules/ # escolha sua stack +> cp -r everything-claude-code/rules/common ~/.claude/rules/common +> cp -r everything-claude-code/rules/typescript ~/.claude/rules/typescript # escolha sua stack > > # Opção B: Regras no nível do projeto (aplica apenas ao projeto atual) > mkdir -p .claude/rules -> cp -r everything-claude-code/rules/common/* .claude/rules/ +> cp -r everything-claude-code/rules/common .claude/rules/common > ``` --- @@ -362,8 +362,8 @@ git clone https://github.com/affaan-m/everything-claude-code.git cp everything-claude-code/agents/*.md ~/.claude/agents/ # Copiar regras (comuns + específicas da linguagem) -cp -r everything-claude-code/rules/common/* ~/.claude/rules/ -cp -r everything-claude-code/rules/typescript/* ~/.claude/rules/ +cp -r everything-claude-code/rules/common ~/.claude/rules/common +cp -r everything-claude-code/rules/typescript ~/.claude/rules/typescript # Copiar comandos cp everything-claude-code/commands/*.md ~/.claude/commands/ diff --git a/docs/tr/README.md b/docs/tr/README.md index dc1ecc5b..b7ccc200 100644 --- a/docs/tr/README.md +++ b/docs/tr/README.md @@ -390,7 +390,7 @@ Evet. Seçenek 2'yi (manuel kurulum) kullanın ve yalnızca ihtiyacınız olanı cp everything-claude-code/agents/*.md ~/.claude/agents/ # Sadece rule'lar -cp -r everything-claude-code/rules/common/* ~/.claude/rules/ +cp -r everything-claude-code/rules/common ~/.claude/rules/common ``` Her component tamamen bağımsızdır. diff --git a/docs/zh-CN/AGENTS.md b/docs/zh-CN/AGENTS.md index 7ab620ba..134372bf 100644 --- a/docs/zh-CN/AGENTS.md +++ b/docs/zh-CN/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — 智能体指令 -这是一个**生产就绪的 AI 编码插件**,提供 60 个专业代理、228 项技能、75 条命令以及自动化钩子工作流,用于软件开发。 +这是一个**生产就绪的 AI 编码插件**,提供 60 个专业代理、229 项技能、75 条命令以及自动化钩子工作流,用于软件开发。 **版本:** 2.0.0-rc.1 @@ -147,7 +147,7 @@ ``` agents/ — 60 个专业子代理 -skills/ — 228 个工作流技能和领域知识 +skills/ — 229 个工作流技能和领域知识 commands/ — 75 个斜杠命令 hooks/ — 基于触发的自动化 rules/ — 始终遵循的指导方针(通用 + 每种语言) diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 4794bef1..cf8ae722 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -224,7 +224,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/" /plugin list ecc@ecc ``` -**搞定!** 你现在可以使用 60 个智能体、228 项技能和 75 个命令了。 +**搞定!** 你现在可以使用 60 个智能体、229 项技能和 75 个命令了。 *** @@ -637,16 +637,16 @@ Claude Code v2.1+ **会自动加载** 任何已安装插件中的 `hooks/hooks.j > > # 选项 A:用户级规则(适用于所有项目) > mkdir -p ~/.claude/rules -> cp -r everything-claude-code/rules/common/* ~/.claude/rules/ -> cp -r everything-claude-code/rules/typescript/* ~/.claude/rules/ # 选择您的技术栈 -> cp -r everything-claude-code/rules/python/* ~/.claude/rules/ -> cp -r everything-claude-code/rules/golang/* ~/.claude/rules/ -> cp -r everything-claude-code/rules/php/* ~/.claude/rules/ +> cp -r everything-claude-code/rules/common ~/.claude/rules/common +> cp -r everything-claude-code/rules/typescript ~/.claude/rules/typescript # 选择您的技术栈 +> cp -r everything-claude-code/rules/python ~/.claude/rules/python +> cp -r everything-claude-code/rules/golang ~/.claude/rules/golang +> cp -r everything-claude-code/rules/php ~/.claude/rules/php > > # 选项 B:项目级规则(仅适用于当前项目) > mkdir -p .claude/rules -> cp -r everything-claude-code/rules/common/* .claude/rules/ -> cp -r everything-claude-code/rules/typescript/* .claude/rules/ # 选择您的技术栈 +> cp -r everything-claude-code/rules/common .claude/rules/common +> cp -r everything-claude-code/rules/typescript .claude/rules/typescript # 选择您的技术栈 > ``` *** @@ -663,11 +663,11 @@ git clone https://github.com/affaan-m/everything-claude-code.git cp everything-claude-code/agents/*.md ~/.claude/agents/ # Copy rules (common + language-specific) -cp -r everything-claude-code/rules/common/* ~/.claude/rules/ -cp -r everything-claude-code/rules/typescript/* ~/.claude/rules/ # pick your stack -cp -r everything-claude-code/rules/python/* ~/.claude/rules/ -cp -r everything-claude-code/rules/golang/* ~/.claude/rules/ -cp -r everything-claude-code/rules/php/* ~/.claude/rules/ +cp -r everything-claude-code/rules/common ~/.claude/rules/common +cp -r everything-claude-code/rules/typescript ~/.claude/rules/typescript # pick your stack +cp -r everything-claude-code/rules/python ~/.claude/rules/python +cp -r everything-claude-code/rules/golang ~/.claude/rules/golang +cp -r everything-claude-code/rules/php ~/.claude/rules/php # Copy maintained commands cp everything-claude-code/commands/*.md ~/.claude/commands/ @@ -885,7 +885,7 @@ claude cp everything-claude-code/agents/*.md ~/.claude/agents/ # Just rules -cp -r everything-claude-code/rules/common/* ~/.claude/rules/ +cp -r everything-claude-code/rules/common ~/.claude/rules/common ``` 每个组件都是完全独立的。 @@ -1138,7 +1138,7 @@ opencode |---------|-------------|----------|--------| | 智能体 | PASS: 60 个 | PASS: 12 个 | **Claude Code 领先** | | 命令 | PASS: 75 个 | PASS: 35 个 | **Claude Code 领先** | -| 技能 | PASS: 228 项 | PASS: 37 项 | **Claude Code 领先** | +| 技能 | PASS: 229 项 | PASS: 37 项 | **Claude Code 领先** | | 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** | | 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** | | MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** | @@ -1246,7 +1246,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以 |---------|------------|------------|-----------|----------| | **智能体** | 60 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 | | **命令** | 75 | 共享 | 基于指令 | 35 | -| **技能** | 228 | 共享 | 10 (原生格式) | 37 | +| **技能** | 229 | 共享 | 10 (原生格式) | 37 | | **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 | | **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 | | **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 | diff --git a/docs/zh-CN/skills/configure-ecc/SKILL.md b/docs/zh-CN/skills/configure-ecc/SKILL.md index 02280652..caf6590c 100644 --- a/docs/zh-CN/skills/configure-ecc/SKILL.md +++ b/docs/zh-CN/skills/configure-ecc/SKILL.md @@ -239,13 +239,13 @@ cp -R "${src%/}" "$TARGET/skills/$(basename "${src%/}")" 执行安装: ```bash -# Common rules (flat copy into rules/) -cp -r $ECC_ROOT/rules/common/* $TARGET/rules/ +# Common rules +cp -r $ECC_ROOT/rules/common $TARGET/rules/common -# Language-specific rules (flat copy into rules/) -cp -r $ECC_ROOT/rules/typescript/* $TARGET/rules/ # if selected -cp -r $ECC_ROOT/rules/python/* $TARGET/rules/ # if selected -cp -r $ECC_ROOT/rules/golang/* $TARGET/rules/ # if selected +# Language-specific rules (preserve per-language directories) +cp -r $ECC_ROOT/rules/typescript $TARGET/rules/typescript # if selected +cp -r $ECC_ROOT/rules/python $TARGET/rules/python # if selected +cp -r $ECC_ROOT/rules/golang $TARGET/rules/golang # if selected ``` **重要**:如果用户选择了任何特定语言的规则但**没有**选择通用规则,警告他们: diff --git a/manifests/install-modules.json b/manifests/install-modules.json index 3c9f722e..61d6ae41 100644 --- a/manifests/install-modules.json +++ b/manifests/install-modules.json @@ -199,7 +199,8 @@ "skills/database-migrations", "skills/jpa-patterns", "skills/mysql-patterns", - "skills/postgres-patterns" + "skills/postgres-patterns", + "skills/prisma-patterns" ], "targets": [ "claude", diff --git a/package.json b/package.json index 501d337d..098c4ada 100644 --- a/package.json +++ b/package.json @@ -215,6 +215,7 @@ "skills/perl-testing/", "skills/plankton-code-quality/", "skills/postgres-patterns/", + "skills/prisma-patterns/", "skills/product-capability/", "skills/production-audit/", "skills/production-scheduling/", diff --git a/rules/ruby/hooks.md b/rules/ruby/hooks.md index 0415fe61..1ec61d86 100644 --- a/rules/ruby/hooks.md +++ b/rules/ruby/hooks.md @@ -15,7 +15,7 @@ paths: Configure project-local hooks to prefer binstubs and checked-in tooling: - **RuboCop**: run `bundle exec rubocop -A ` or the project's safer formatter command after Ruby edits. -- **Brakeman**: run `bundle exec brakeman --no-pager` after security-sensitive Rails changes. +- **Brakeman**: run `bundle exec brakeman --no-progress` after security-sensitive Rails changes. - **Tests**: run the narrowest matching `bin/rails test ...` or `bundle exec rspec ...` command for touched files. - **Bundler audit**: run `bundle exec bundle-audit check --update` when `Gemfile` or `Gemfile.lock` changes and the project has bundler-audit installed. @@ -29,7 +29,7 @@ Configure project-local hooks to prefer binstubs and checked-in tooling: ```bash bundle exec rubocop -bundle exec brakeman --no-pager +bundle exec brakeman --no-progress bin/rails test bundle exec rspec ``` diff --git a/rules/ruby/security.md b/rules/ruby/security.md index 1ecf0645..4821c2c0 100644 --- a/rules/ruby/security.md +++ b/rules/ruby/security.md @@ -34,8 +34,8 @@ paths: - Run dependency checks when the lockfile changes: ```bash -bundle audit check --update -bundle exec brakeman --no-pager +bundle exec bundle-audit check --update +bundle exec brakeman --no-progress ``` - Review new gems for maintainer activity, native extension risk, transitive dependencies, and whether the same behavior can be implemented with Rails core. diff --git a/scripts/hooks/config-protection.js b/scripts/hooks/config-protection.js index 8592542e..625da3b7 100644 --- a/scripts/hooks/config-protection.js +++ b/scripts/hooks/config-protection.js @@ -7,12 +7,13 @@ * the actual code. This hook steers the agent back to fixing the source. * * Exit codes: - * 0 = allow (not a config file) - * 2 = block (config file modification attempted) + * 0 = allow (not a config file, or first-time creation of one) + * 2 = block (existing config file modification attempted) */ 'use strict'; +const fs = require('fs'); const path = require('path'); const MAX_STDIN = 1024 * 1024; @@ -58,7 +59,7 @@ const PROTECTED_FILES = new Set([ '.stylelintrc.yml', '.markdownlint.json', '.markdownlint.yaml', - '.markdownlintrc', + '.markdownlintrc' ]); function parseInput(inputOrRaw) { @@ -94,13 +95,41 @@ function run(inputOrRaw, options = {}) { const basename = path.basename(filePath); if (PROTECTED_FILES.has(basename)) { + // Allow first-time creation — there's no existing config to weaken. + // The hook's purpose is blocking modifications; writing a brand-new + // config file in a project that has none is a legitimate bootstrap + // path (e.g. scaffolding ESLint into a fresh repo). + // + // Fail closed on any stat error other than ENOENT. Use lstatSync so a + // symlink at the protected path is treated as present even if its target + // is missing — a dangling symlink at e.g. .eslintrc.js still represents + // an existing config entry that an agent should not silently replace. + // fs.existsSync would swallow EACCES/EPERM as false; lstatSync exposes + // the error code so we can treat only genuine "path not found" (ENOENT) + // as absent. + let exists = true; + try { + fs.lstatSync(filePath); + // lstat succeeded — something (file, dir, or symlink) exists here. + } catch (err) { + if (err && err.code === 'ENOENT') { + exists = false; + } + // Any other error (EACCES, EPERM, ELOOP, etc.) leaves exists=true + // so the guard is never silently weakened. + } + + if (!exists) { + return { exitCode: 0 }; + } + return { exitCode: 2, stderr: `BLOCKED: Modifying ${basename} is not allowed. ` + 'Fix the source code to satisfy linter/formatter rules instead of ' + 'weakening the config. If this is a legitimate config change, ' + - 'disable the config-protection hook temporarily.', + 'disable the config-protection hook temporarily.' }; } @@ -125,7 +154,7 @@ process.stdin.on('data', chunk => { process.stdin.on('end', () => { const result = run(raw, { truncated, - maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN, + maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN }); if (result.stderr) { diff --git a/scripts/hooks/cost-tracker.js b/scripts/hooks/cost-tracker.js index a3f2f896..291345cc 100755 --- a/scripts/hooks/cost-tracker.js +++ b/scripts/hooks/cost-tracker.js @@ -1,63 +1,157 @@ #!/usr/bin/env node /** - * Cost Tracker Hook + * Cost Tracker Hook (v2) * - * Appends lightweight session usage metrics to ~/.claude/metrics/costs.jsonl. + * Reads transcript_path from Stop hook stdin, sums usage across all + * assistant turns in the session JSONL, and appends one row to + * ~/.claude/metrics/costs.jsonl. + * + * Stop hook stdin payload: { session_id, transcript_path, cwd, hook_event_name, ... } + * The Stop payload does NOT include `usage` or `model` directly. The previous + * version of this hook expected those fields and silently produced zero-filled + * rows (verified: 2,340 rows captured with 0.0% non-zero token rate over 52 + * days). The fix is to read the transcript file Claude Code already passes us. + * + * JSONL assistant entry shape (per Claude Code): + * { type: "assistant", message: { model, usage: { input_tokens, output_tokens, + * cache_creation_input_tokens, cache_read_input_tokens } } } + * + * Cumulative behavior: Stop fires per assistant response, not per session. + * Each row therefore represents the cumulative session total up to that point. + * To get per-session cost, take the last row per session_id. To get per-day + * spend, aggregate. */ 'use strict'; +const fs = require('fs'); const path = require('path'); const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils'); -const { estimateCost } = require('../lib/cost-estimate'); const { sanitizeSessionId } = require('../lib/session-bridge'); -const MAX_STDIN = 1024 * 1024; -let raw = ''; +// Approximate per-1M-token billing rates (USD). +// Cache creation: 1.25x input rate. Cache read: 0.1x input rate. +const RATE_TABLE = { + haiku: { in: 0.80, out: 4.0, cacheWrite: 1.00, cacheRead: 0.08 }, + sonnet: { in: 3.00, out: 15.0, cacheWrite: 3.75, cacheRead: 0.30 }, + opus: { in: 15.00, out: 75.0, cacheWrite: 18.75, cacheRead: 1.50 } +}; -function toNumber(value) { - const n = Number(value); +function getRates(model) { + const m = String(model || '').toLowerCase(); + if (m.includes('haiku')) return RATE_TABLE.haiku; + if (m.includes('opus')) return RATE_TABLE.opus; + return RATE_TABLE.sonnet; +} + +function toNumber(v) { + const n = Number(v); return Number.isFinite(n) ? n : 0; } +/** + * Scan the session JSONL and sum token usage across all assistant turns. + * Returns { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model } + * or null on read failure. + */ +function sumUsageFromTranscript(transcriptPath) { + let content; + try { + content = fs.readFileSync(transcriptPath, 'utf8'); + } catch { + return null; + } + + let inputTokens = 0; + let outputTokens = 0; + let cacheWriteTokens = 0; + let cacheReadTokens = 0; + let model = 'unknown'; + + for (const line of content.split('\n')) { + if (!line.trim()) continue; + let entry; + try { entry = JSON.parse(line); } catch { continue; } + + if (entry.type !== 'assistant') continue; + const msg = entry.message; + if (!msg || !msg.usage) continue; + + const u = msg.usage; + inputTokens += toNumber(u.input_tokens); + outputTokens += toNumber(u.output_tokens); + cacheWriteTokens += toNumber(u.cache_creation_input_tokens); + cacheReadTokens += toNumber(u.cache_read_input_tokens); + + if (msg.model && msg.model !== 'unknown') model = msg.model; + } + + return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model }; +} + +const MAX_STDIN = 64 * 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); - } + if (raw.length < MAX_STDIN) raw += chunk.substring(0, MAX_STDIN - raw.length); }); process.stdin.on('end', () => { try { const input = raw.trim() ? JSON.parse(raw) : {}; - const usage = input.usage || input.token_usage || {}; - const inputTokens = toNumber(usage.input_tokens || usage.prompt_tokens || 0); - const outputTokens = toNumber(usage.output_tokens || usage.completion_tokens || 0); - const model = String(input.model || input._cursor?.model || process.env.CLAUDE_MODEL || 'unknown'); + const transcriptPath = (typeof input.transcript_path === 'string' && input.transcript_path) + ? input.transcript_path + : process.env.CLAUDE_TRANSCRIPT_PATH || null; + const sessionId = sanitizeSessionId(input.session_id) || sanitizeSessionId(process.env.ECC_SESSION_ID) || sanitizeSessionId(process.env.CLAUDE_SESSION_ID) || 'default'; + let usageTotals = null; + if (transcriptPath && fs.existsSync(transcriptPath)) { + usageTotals = sumUsageFromTranscript(transcriptPath); + } + + const { + inputTokens = 0, + outputTokens = 0, + cacheWriteTokens = 0, + cacheReadTokens = 0, + model = 'unknown' + } = usageTotals || {}; + + const rates = getRates(model); + const estimatedCostUsd = Math.round(( + (inputTokens / 1e6) * rates.in + + (outputTokens / 1e6) * rates.out + + (cacheWriteTokens / 1e6) * rates.cacheWrite + + (cacheReadTokens / 1e6) * rates.cacheRead + ) * 1e6) / 1e6; + const metricsDir = path.join(getClaudeDir(), 'metrics'); ensureDir(metricsDir); const row = { - timestamp: new Date().toISOString(), - session_id: sessionId, + timestamp: new Date().toISOString(), + session_id: sessionId, + transcript_path: transcriptPath || '', model, - input_tokens: inputTokens, - output_tokens: outputTokens, - estimated_cost_usd: estimateCost(model, inputTokens, outputTokens) + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_write_tokens: cacheWriteTokens, + cache_read_tokens: cacheReadTokens, + estimated_cost_usd: estimatedCostUsd }; appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`); } catch { - // Keep hook non-blocking. + // Non-blocking — never fail the Stop hook. } + // Pass stdin through (required by ECC hook convention). process.stdout.write(raw); }); diff --git a/scripts/hooks/pre-bash-dev-server-block.js b/scripts/hooks/pre-bash-dev-server-block.js index 9c0861b8..cd663fa7 100755 --- a/scripts/hooks/pre-bash-dev-server-block.js +++ b/scripts/hooks/pre-bash-dev-server-block.js @@ -4,6 +4,10 @@ const MAX_STDIN = 1024 * 1024; const path = require('path'); const { splitShellSegments } = require('../lib/shell-split'); +const { + extractCommandSubstitutions, + extractSubshellGroups +} = require('../lib/shell-substitution'); const DEV_COMMAND_WORDS = new Set([ 'npm', @@ -123,6 +127,8 @@ function getLeadingCommandWord(segment) { continue; } + if (token === '{' || token === '}') continue; + if (/^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token)) continue; const normalizedToken = normalizeCommandWord(token); @@ -154,23 +160,55 @@ process.stdin.on('data', chunk => { } }); +const TMUX_LAUNCHER = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/; +const DEV_PATTERN = /\b(npm\s+run\s+dev|pnpm(?:\s+run)?\s+dev|yarn(?:\s+run)?\s+dev|bun(?:\s+run)?\s+dev)\b/; + +/** + * Collect every command-line segment we should evaluate. Returns the top-level + * segments first, then segments harvested from `$(...)` / backtick command + * substitutions and plain `(...)` subshell groups, recursively. + * + * Without this expansion the leading-command and dev-pattern check below only + * sees the outermost command, so wrappers like `$(npm run dev)` and + * `(npm run dev)` (which still spawn a dev server) sneak past. + */ +function collectCheckSegments(cmd) { + const segments = [...splitShellSegments(cmd)]; + const queue = [cmd]; + const seen = new Set(); + + while (queue.length) { + const current = queue.shift(); + if (seen.has(current)) continue; + seen.add(current); + + for (const body of extractCommandSubstitutions(current)) { + for (const seg of splitShellSegments(body)) segments.push(seg); + queue.push(body); + } + for (const body of extractSubshellGroups(current)) { + for (const seg of splitShellSegments(body)) segments.push(seg); + queue.push(body); + } + } + + return segments; +} + +function isBlockedDevSegment(segment) { + const commandWord = getLeadingCommandWord(segment); + if (!commandWord || !DEV_COMMAND_WORDS.has(commandWord)) return false; + return DEV_PATTERN.test(segment) && !TMUX_LAUNCHER.test(segment); +} + process.stdin.on('end', () => { try { const input = JSON.parse(raw); const cmd = String(input.tool_input?.command || ''); if (process.platform !== 'win32') { - const segments = splitShellSegments(cmd); - const tmuxLauncher = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/; - const devPattern = /\b(npm\s+run\s+dev|pnpm(?:\s+run)?\s+dev|yarn\s+dev|bun\s+run\s+dev)\b/; - - const hasBlockedDev = segments.some(segment => { - const commandWord = getLeadingCommandWord(segment); - if (!commandWord || !DEV_COMMAND_WORDS.has(commandWord)) { - return false; - } - return devPattern.test(segment) && !tmuxLauncher.test(segment); - }); + const segments = collectCheckSegments(cmd); + const hasBlockedDev = segments.some(isBlockedDevSegment); if (hasBlockedDev) { console.error('[Hook] BLOCKED: Dev server must run in tmux for log access'); diff --git a/scripts/hooks/suggest-compact.js b/scripts/hooks/suggest-compact.js index be3f2e79..0e9d5e66 100644 --- a/scripts/hooks/suggest-compact.js +++ b/scripts/hooks/suggest-compact.js @@ -19,7 +19,8 @@ const { getTempDir, writeFile, readStdinJson, - log + log, + output } = require('../lib/utils'); async function resolveSessionId() { @@ -77,14 +78,25 @@ async function main() { writeFile(counterFile, String(count)); } - // Suggest compact after threshold tool calls + // Suggest compact after threshold tool calls. + // + // log() writes to stderr (debug log). Per the Claude Code hooks guide, + // non-blocking PreToolUse stderr (exit 0) is only written to the debug log; + // it does not reach the model. To inject a user-facing suggestion without + // blocking the tool call, emit structured JSON to stdout with + // hookSpecificOutput.additionalContext — the documented mechanism for + // PreToolUse hooks to add context to the next model turn. if (count === threshold) { - log(`[StrategicCompact] ${threshold} tool calls reached - consider /compact if transitioning phases`); + const msg = `[StrategicCompact] ${threshold} tool calls reached - consider /compact if transitioning phases`; + log(msg); + output({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: msg } }); } // Suggest at regular intervals after threshold (every 25 calls from threshold) if (count > threshold && (count - threshold) % 25 === 0) { - log(`[StrategicCompact] ${count} tool calls - good checkpoint for /compact if context is stale`); + const msg = `[StrategicCompact] ${count} tool calls - good checkpoint for /compact if context is stale`; + log(msg); + output({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: msg } }); } process.exit(0); diff --git a/scripts/lib/shell-substitution.js b/scripts/lib/shell-substitution.js new file mode 100644 index 00000000..dd5d6c74 --- /dev/null +++ b/scripts/lib/shell-substitution.js @@ -0,0 +1,246 @@ +'use strict'; + +/** + * Extract executable command-substitution bodies from a shell line. + * + * Single quotes are literal, so substitutions inside them are ignored; + * double quotes still permit substitutions, so those bodies are scanned + * before quoted text is stripped. Returns each substitution body plus + * any nested substitutions discovered recursively. + * + * Originally introduced in scripts/hooks/gateguard-fact-force.js + * (PR #1853 round 2). Extracted to a shared lib so other PreToolUse + * hooks that need the same "scan inside `$(...)` and backticks" + * behavior can reuse it without duplicating the parser. + * + * @param {string} input + * @returns {string[]} + */ +function extractCommandSubstitutions(input) { + const source = String(input || ''); + const substitutions = []; + let inSingle = false; + let inDouble = false; + + for (let i = 0; i < source.length; i++) { + const ch = source[i]; + const prev = source[i - 1]; + + if (ch === '\\' && !inSingle) { + i += 1; + continue; + } + + if (ch === "'" && !inDouble && prev !== '\\') { + inSingle = !inSingle; + continue; + } + + if (ch === '"' && !inSingle && prev !== '\\') { + inDouble = !inDouble; + continue; + } + + if (inSingle) { + continue; + } + + if (ch === '`') { + let body = ''; + i += 1; + while (i < source.length) { + const inner = source[i]; + if (inner === '\\') { + body += inner; + if (i + 1 < source.length) { + body += source[i + 1]; + i += 2; + continue; + } + } + if (inner === '`') { + break; + } + body += inner; + i += 1; + } + if (body.trim()) { + substitutions.push(body); + substitutions.push(...extractCommandSubstitutions(body)); + } + continue; + } + + if (ch === '$' && source[i + 1] === '(') { + let depth = 1; + let body = ''; + let bodyInSingle = false; + let bodyInDouble = false; + i += 2; + while (i < source.length && depth > 0) { + const inner = source[i]; + const innerPrev = source[i - 1]; + if (inner === '\\' && !bodyInSingle) { + body += inner; + if (i + 1 < source.length) { + body += source[i + 1]; + i += 2; + continue; + } + } + if (inner === "'" && !bodyInDouble && innerPrev !== '\\') { + bodyInSingle = !bodyInSingle; + } else if (inner === '"' && !bodyInSingle && innerPrev !== '\\') { + bodyInDouble = !bodyInDouble; + } else if (!bodyInSingle && !bodyInDouble) { + if (inner === '(') { + depth += 1; + } else if (inner === ')') { + depth -= 1; + if (depth === 0) { + break; + } + } + } + body += inner; + i += 1; + } + if (body.trim()) { + substitutions.push(body); + substitutions.push(...extractCommandSubstitutions(body)); + } + } + } + + return substitutions; +} + +/** + * Extract bodies of plain `(...)` subshell groups. + * + * Bash treats `(npm run dev)` as a subshell that executes its contents, but + * the regex-light segment splitters used by our PreToolUse hooks don't peer + * inside those parens. This helper finds top-level `(...)` groups (skipping + * `$(...)` command substitutions and backticks, which `extractCommandSubstitutions` + * already covers) and returns each body, recursing for nested groups. + * + * Quote semantics: + * - Single quotes are literal: `'( ... )'` is a string, not a subshell. + * - Double quotes are literal *for parens*: `"( ... )"` is a string too — + * bash only honors `$( )` inside double quotes, not bare `( )`. + * + * @param {string} input + * @returns {string[]} + */ +function extractSubshellGroups(input) { + const source = String(input || ''); + const groups = []; + let inSingle = false; + let inDouble = false; + + for (let i = 0; i < source.length; i++) { + const ch = source[i]; + const prev = source[i - 1]; + + if (ch === '\\' && !inSingle) { + i += 1; + continue; + } + + if (ch === "'" && !inDouble && prev !== '\\') { + inSingle = !inSingle; + continue; + } + + if (ch === '"' && !inSingle && prev !== '\\') { + inDouble = !inDouble; + continue; + } + + if (inSingle || inDouble) { + continue; + } + + if (ch === '$' && source[i + 1] === '(') { + let depth = 1; + let skipInSingle = false; + let skipInDouble = false; + i += 2; + while (i < source.length && depth > 0) { + const inner = source[i]; + const innerPrev = source[i - 1]; + if (inner === '\\' && !skipInSingle) { + i += 2; + continue; + } + if (inner === "'" && !skipInDouble && innerPrev !== '\\') { + skipInSingle = !skipInSingle; + } else if (inner === '"' && !skipInSingle && innerPrev !== '\\') { + skipInDouble = !skipInDouble; + } else if (!skipInSingle && !skipInDouble) { + if (inner === '(') depth += 1; + else if (inner === ')') depth -= 1; + } + i += 1; + } + i -= 1; + continue; + } + + if (ch === '`') { + i += 1; + while (i < source.length && source[i] !== '`') { + if (source[i] === '\\' && i + 1 < source.length) { + i += 2; + continue; + } + i += 1; + } + continue; + } + + if (ch === '(') { + let depth = 1; + let body = ''; + let bodyInSingle = false; + let bodyInDouble = false; + i += 1; + while (i < source.length && depth > 0) { + const inner = source[i]; + const innerPrev = source[i - 1]; + if (inner === '\\' && !bodyInSingle) { + body += inner; + if (i + 1 < source.length) { + body += source[i + 1]; + i += 2; + continue; + } + } + if (inner === "'" && !bodyInDouble && innerPrev !== '\\') { + bodyInSingle = !bodyInSingle; + } else if (inner === '"' && !bodyInSingle && innerPrev !== '\\') { + bodyInDouble = !bodyInDouble; + } else if (!bodyInSingle && !bodyInDouble) { + if (inner === '(') { + depth += 1; + } else if (inner === ')') { + depth -= 1; + if (depth === 0) { + break; + } + } + } + body += inner; + i += 1; + } + if (body.trim()) { + groups.push(body); + groups.push(...extractSubshellGroups(body)); + } + } + } + + return groups; +} + +module.exports = { extractCommandSubstitutions, extractSubshellGroups }; diff --git a/skills/configure-ecc/SKILL.md b/skills/configure-ecc/SKILL.md index 50f9ad86..679ae7fd 100644 --- a/skills/configure-ecc/SKILL.md +++ b/skills/configure-ecc/SKILL.md @@ -234,13 +234,13 @@ Options: Execute installation: ```bash -# Common rules (flat copy into rules/) -cp -r $ECC_ROOT/rules/common/* $TARGET/rules/ +# Common rules +cp -r $ECC_ROOT/rules/common $TARGET/rules/common -# Language-specific rules (flat copy into rules/) -cp -r $ECC_ROOT/rules/typescript/* $TARGET/rules/ # if selected -cp -r $ECC_ROOT/rules/python/* $TARGET/rules/ # if selected -cp -r $ECC_ROOT/rules/golang/* $TARGET/rules/ # if selected +# Language-specific rules (preserve per-language directories) +cp -r $ECC_ROOT/rules/typescript $TARGET/rules/typescript # if selected +cp -r $ECC_ROOT/rules/python $TARGET/rules/python # if selected +cp -r $ECC_ROOT/rules/golang $TARGET/rules/golang # if selected ``` **Important**: If the user selects any language-specific rules but NOT common rules, warn them: diff --git a/skills/prisma-patterns/SKILL.md b/skills/prisma-patterns/SKILL.md new file mode 100644 index 00000000..8524ec40 --- /dev/null +++ b/skills/prisma-patterns/SKILL.md @@ -0,0 +1,371 @@ +--- +name: prisma-patterns +description: Prisma ORM patterns for TypeScript backends — schema design, query optimization, transactions, pagination, and critical traps like updateMany returning count not records, $transaction timeouts, migrate dev resetting the DB, @updatedAt skipped on bulk writes, and serverless connection exhaustion. +origin: ECC +--- + +# Prisma Patterns + +Production patterns and non-obvious traps for Prisma ORM in TypeScript backends. +Tested against Prisma 5.x and 6.x. Some behaviors differ from Prisma 4. + +Check the Prisma version before applying version-specific patterns: + +```bash +npx prisma --version +``` + +Prisma 5 introduced `relationJoins`, which can load relations via JOIN rather than separate queries depending on query strategy and configuration. The `omit` field modifier and `prisma.$extends` Client Extensions API were also added. Note: `relationJoins` can cause row explosion on large 1:N relations or deep nested `include` — benchmark both approaches when relations may return many rows per parent. + +## When to Activate + +- Designing or modifying Prisma schema models and relations +- Writing queries, transactions, or pagination logic +- Using `updateMany`, `deleteMany`, or any bulk operation +- Running or planning database migrations +- Deploying to serverless environments (Vercel, Lambda, Cloudflare Workers) +- Implementing soft delete or multi-tenant row filtering + +## Core Concepts + +### ID Strategy + +| Strategy | Use When | Avoid When | +|---|---|---| +| `@default(cuid())` | Default choice — URL-safe, sortable, no collisions | Sequential IDs needed for external systems | +| `@default(uuid())` | Interoperability with non-Prisma systems required | High-write tables (random UUIDs fragment B-tree indexes) | +| `@default(autoincrement())` | Internal join tables, audit logs | Public-facing IDs (exposes record count) | + +### Schema Defaults + +```prisma +model User { + id String @id @default(cuid()) + email String @unique // @unique already creates an index — no @@index needed + name String + role Role @default(USER) + posts Post[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + @@index([createdAt]) + @@index([deletedAt, createdAt]) // composite for soft-delete + sort queries +} +``` + +- Add `@@index` on every foreign key and column used in `WHERE` or `ORDER BY`. +- Declare `deletedAt DateTime?` upfront when soft delete is a foreseeable requirement — adding it later requires a migration on a live table. +- `updatedAt @updatedAt` is set automatically by Prisma on `update` and `upsert` only (see Anti-Patterns for bulk update trap). + +### `include` vs `select` + +| | `include` | `select` | +|---|---|---| +| Returns | All scalar fields + specified relations | Only specified fields | +| Use when | You need most fields plus a relation | Hot paths, large tables, avoiding over-fetch | +| Performance | May over-fetch on wide tables | Minimal payload, faster on large datasets | +| Prisma 5 note | Uses JOIN by default (`relationJoins`) | Same | + +```ts +// include — all columns + relation +const user = await prisma.user.findUnique({ + where: { id }, + include: { posts: { select: { id: true, title: true } } }, +}); + +// select — explicit allowlist +const user = await prisma.user.findUnique({ + where: { id }, + select: { id: true, email: true, name: true }, +}); +``` + +Never return raw Prisma entities from API responses — map to response DTOs to control exposed fields: + +```ts +// BAD: leaks passwordHash, deletedAt, internal fields +return await prisma.user.findUniqueOrThrow({ where: { id } }); + +// GOOD: explicit DTO mapping +const user = await prisma.user.findUniqueOrThrow({ where: { id } }); +return { id: user.id, name: user.name, email: user.email }; +``` + +### Transaction Form Selection + +| Situation | Use | +|---|---| +| Independent operations, no inter-dependency | Array form | +| Later step depends on earlier result | Interactive form | +| External calls (email, HTTP) involved | Outside transaction entirely | + +```ts +// Array form — batched in one round trip +const [user, post] = await prisma.$transaction([ + prisma.user.update({ where: { id }, data: { name } }), + prisma.post.create({ data: { title, authorId: id } }), +]); + +// Interactive form — use tx client only, never the outer prisma client +const post = await prisma.$transaction(async (tx) => { + const user = await tx.user.findUniqueOrThrow({ where: { id } }); + if (user.role !== 'ADMIN') throw new Error('Forbidden'); + return tx.post.create({ data: { title, authorId: user.id } }); +}); +``` + +### PrismaClient Singleton + +Each `PrismaClient` instance opens its own connection pool. Instantiate once. + +```ts +// lib/prisma.ts +import { PrismaClient } from '@prisma/client'; + +const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; + +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'], + }); + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; +``` + +The `globalThis` pattern prevents duplicate instances during hot reload (Next.js, nodemon, ts-node-dev). + +### N+1 Problem + +Loading relations inside a loop issues one query per row. + +```ts +// BAD: N+1 — one extra query per user +const users = await prisma.user.findMany(); +for (const user of users) { + const posts = await prisma.post.findMany({ where: { authorId: user.id } }); +} + +// GOOD: single query +const users = await prisma.user.findMany({ include: { posts: true } }); +``` + +With Prisma 5+ `relationJoins`, the `include` form uses a single JOIN. On large 1:N sets this may increase result set size — benchmark both approaches if the relation can return many rows per parent. + +## Code Examples + +### Cursor Pagination (preferred for feeds and large datasets) + +```ts +async function getPosts(cursor?: string, limit = 20) { + const items = await prisma.post.findMany({ + where: { published: true }, + orderBy: [ + { createdAt: 'desc' }, + { id: 'desc' }, // secondary sort prevents unstable pagination on duplicate timestamps + ], + take: limit + 1, + ...(cursor && { cursor: { id: cursor }, skip: 1 }), + }); + + const hasNextPage = items.length > limit; + if (hasNextPage) items.pop(); + + return { items, nextCursor: hasNextPage ? items[items.length - 1].id : null }; +} +``` + +Fetch `limit + 1` and pop — canonical way to detect `hasNextPage` without an extra count query. Always include a unique field (e.g. `id`) as a secondary `orderBy` to prevent unstable pagination when multiple rows share the same timestamp. Use offset pagination only when users need to jump to arbitrary pages (admin tables). + +### Soft Delete + +```ts +// Always filter explicitly — do not rely on middleware (hides behavior, hard to debug) +const activeUsers = await prisma.user.findMany({ where: { deletedAt: null } }); + +await prisma.user.update({ where: { id }, data: { deletedAt: new Date() } }); +await prisma.user.update({ where: { id }, data: { deletedAt: null } }); // restore +``` + +### Error Handling + +```ts +import { Prisma } from '@prisma/client'; + +try { + await prisma.user.create({ data: { email } }); +} catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === 'P2002') throw new ConflictError('Email already exists'); + if (e.code === 'P2025') throw new NotFoundError('Record not found'); + if (e.code === 'P2003') throw new BadRequestError('Referenced record does not exist'); + } + throw e; +} +``` + +Common codes: `P2002` unique violation · `P2025` not found · `P2003` foreign key violation. + +Catch at the service boundary and translate to domain errors. Never expose raw Prisma messages to API consumers. + +### Connection Pool — Serverless + +Embed connection params directly in `DATABASE_URL` — string concatenation breaks if the URL already has query parameters (e.g. `?schema=public`): + +```bash +# .env — preferred: embed params in the URL +DATABASE_URL="postgresql://user:pass@host/db?connection_limit=1&pool_timeout=20" + +# With an external pooler (PgBouncer, Supabase pooler) +DATABASE_URL="postgresql://user:pass@host/db?pgbouncer=true&connection_limit=1" +``` + +```ts +// Vercel, AWS Lambda, and similar serverless runtimes: cap pool to 1 per instance +// connection_limit and pool_timeout are controlled via DATABASE_URL +const prisma = new PrismaClient(); +``` + +## Anti-Patterns + +### `updateMany` returns a count, not records + +```ts +// BAD: result is { count: 2 } — users[0] is undefined +const users = await prisma.user.updateMany({ where: { role: 'GUEST' }, data: { role: 'USER' } }); + +// GOOD: capture IDs first, then update, then fetch only the affected rows +const targets = await prisma.user.findMany({ + where: { role: 'GUEST' }, + select: { id: true }, +}); +const ids = targets.map((u) => u.id); +await prisma.user.updateMany({ where: { id: { in: ids } }, data: { role: 'USER' } }); +const updated = await prisma.user.findMany({ where: { id: { in: ids } } }); +``` + +Same applies to `deleteMany` — returns `{ count: n }`, never the deleted rows. + +### `$transaction` interactive form times out after 5 seconds + +```ts +// BAD: external call inside transaction exceeds 5s default → "Transaction already closed" +await prisma.$transaction(async (tx) => { + const user = await tx.user.findUniqueOrThrow({ where: { id } }); + await sendWelcomeEmail(user.email); // external call + await tx.user.update({ where: { id }, data: { emailSent: true } }); +}); + +// GOOD: external calls outside the transaction +const user = await prisma.user.findUniqueOrThrow({ where: { id } }); +await sendWelcomeEmail(user.email); +await prisma.user.update({ where: { id }, data: { emailSent: true } }); + +// Only raise timeout when bulk processing genuinely needs it +await prisma.$transaction(async (tx) => { ... }, { timeout: 30_000 }); +``` + +### `migrate dev` can reset the database + +`migrate dev` detects schema drift and may prompt to reset the DB, dropping all data. + +```bash +# NEVER on shared dev, staging, or production +npx prisma migrate dev --name add_column + +# Safe everywhere except local solo dev +npx prisma migrate deploy + +# Check drift without applying +npx prisma migrate diff \ + --from-migrations ./prisma/migrations \ + --to-schema-datamodel ./prisma/schema.prisma \ + --shadow-database-url "$SHADOW_DATABASE_URL" +``` + +### Manually editing a migration file breaks future deploys + +Prisma checksums every migration file. Editing after apply causes `P3006 checksum mismatch` on every environment where the original already ran. Create a new migration instead. + +### Breaking schema changes require multi-step migration + +Adding `NOT NULL` to an existing column or renaming a column in one migration will lock the table or drop data. Use expand-and-contract: + +```bash +# Step 1: create migration locally, then deploy +npx prisma migrate dev --name add_new_column # local only +npx prisma migrate deploy # staging / production +``` + +```ts +// Step 2: backfill data (run in a script or migration job, not in the shell) +await prisma.user.updateMany({ data: { newColumn: derivedValue } }); +``` + +```bash +# Step 3: create the NOT NULL constraint migration locally, then deploy +npx prisma migrate dev --name make_new_column_required # local only +npx prisma migrate deploy # staging / production +``` + +### `@updatedAt` does not fire on `updateMany` + +`@updatedAt` is set automatically only on `update` and `upsert`. Bulk writes leave it stale. + +```ts +// BAD: updatedAt stays at its old value +await prisma.post.updateMany({ where: { authorId }, data: { published: true } }); + +// GOOD +await prisma.post.updateMany({ + where: { authorId }, + data: { published: true, updatedAt: new Date() }, +}); +``` + +### Soft delete + `findUniqueOrThrow` leaks deleted records + +`findUniqueOrThrow` throws `P2025` only when the row does not exist in the DB. Soft-deleted rows still exist and are returned without error. + +`findUniqueOrThrow` requires a unique constraint field in `where` — adding `deletedAt: null` alongside `id` breaks the type because `{ id, deletedAt }` is not a compound unique constraint. Use `findFirstOrThrow` instead. + +```ts +// BAD: returns soft-deleted user +const user = await prisma.user.findUniqueOrThrow({ where: { id } }); + +// BAD: Prisma type error — { id, deletedAt } is not a unique constraint +const user = await prisma.user.findUniqueOrThrow({ where: { id, deletedAt: null } }); + +// GOOD: findFirstOrThrow supports arbitrary where conditions +const user = await prisma.user.findFirstOrThrow({ where: { id, deletedAt: null } }); +``` + +### `deleteMany` without `where` deletes every row + +```ts +// BAD: silently wipes the table +await prisma.post.deleteMany(); + +// GOOD +await prisma.post.deleteMany({ where: { authorId: userId } }); +``` + +## Best Practices + +| Rule | Reason | +|---|---| +| `migrate deploy` in CI/CD, `migrate dev` only locally | `migrate dev` can reset the DB on drift | +| Map entities to response DTOs | Prevents leaking internal fields | +| Catch `PrismaClientKnownRequestError` at service boundary | Translate to domain errors | +| Prefer `*OrThrow` methods over manual null checks | Throws P2025 automatically; use `findFirstOrThrow` when filtering non-unique fields | +| `connection_limit=1` + external pooler in serverless | Prevents connection exhaustion | +| Always provide `where` on `deleteMany` | Prevents accidental table wipe | +| Set `updatedAt: new Date()` manually in `updateMany` | `@updatedAt` skips bulk writes | + +## Related Skills + +- `nestjs-patterns` — NestJS service layer that integrates Prisma +- `postgres-patterns` — PostgreSQL-level indexing and connection tuning +- `database-migrations` — multi-step migration planning for production +- `backend-patterns` — general API and service layer design diff --git a/tests/hooks/config-protection.test.js b/tests/hooks/config-protection.test.js index 8f01b4b7..ec57c137 100644 --- a/tests/hooks/config-protection.test.js +++ b/tests/hooks/config-protection.test.js @@ -4,6 +4,7 @@ const assert = require('assert'); const fs = require('fs'); +const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); @@ -70,85 +71,249 @@ function runTests() { let passed = 0; let failed = 0; - if (test('blocks protected config file edits through run-with-flags', () => { - const input = { - tool_name: 'Write', - tool_input: { - file_path: '.eslintrc.js', - content: 'module.exports = {};' + if ( + test('blocks protected config file edits through run-with-flags', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-')); + try { + const absPath = path.join(tmpDir, '.eslintrc.js'); + fs.writeFileSync(absPath, 'module.exports = {};'); + + const input = { + tool_name: 'Write', + tool_input: { + file_path: absPath, + content: 'module.exports = {};' + } + }; + + const result = runHook(input); + assert.strictEqual(result.code, 2, 'Expected protected config edit to be blocked'); + assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input'); + assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`); + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } } - }; + }) + ) + passed++; + else failed++; - const result = runHook(input); - assert.strictEqual(result.code, 2, 'Expected protected config edit to be blocked'); - assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input'); - assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`); - })) passed++; else failed++; + if ( + test('passes through safe file edits unchanged', () => { + const input = { + tool_name: 'Write', + tool_input: { + file_path: 'src/index.js', + content: 'console.log("ok");' + } + }; - if (test('passes through safe file edits unchanged', () => { - const input = { - tool_name: 'Write', - tool_input: { - file_path: 'src/index.js', - content: 'console.log("ok");' - } - }; - - const rawInput = JSON.stringify(input); - const result = runHook(input); - assert.strictEqual(result.code, 0, 'Expected safe file edit to pass'); - assert.strictEqual(result.stdout, rawInput, 'Expected exact raw JSON passthrough'); - assert.strictEqual(result.stderr, '', 'Expected no stderr for safe edits'); - })) passed++; else failed++; - - if (test('blocks truncated protected config payloads instead of failing open', () => { - const rawInput = JSON.stringify({ - tool_name: 'Write', - tool_input: { - file_path: '.eslintrc.js', - content: 'x'.repeat(1024 * 1024 + 2048) - } - }); - - const result = runHook(rawInput); - assert.strictEqual(result.code, 2, 'Expected truncated protected payload to be blocked'); - assert.strictEqual(result.stdout, '', 'Blocked truncated payload should not echo raw input'); - assert.ok(result.stderr.includes('Hook input exceeded 1048576 bytes'), `Expected size warning, got: ${result.stderr}`); - assert.ok(result.stderr.includes('truncated payload'), `Expected truncated payload warning, got: ${result.stderr}`); - })) passed++; else failed++; - - if (test('legacy hooks do not echo raw input when they fail without stdout', () => { - const pluginRoot = path.join(__dirname, '..', `tmp-runner-plugin-${Date.now()}`); - const scriptDir = path.join(pluginRoot, 'scripts', 'hooks'); - const scriptPath = path.join(scriptDir, 'legacy-block.js'); - - try { - fs.mkdirSync(scriptDir, { recursive: true }); - fs.writeFileSync( - scriptPath, - '#!/usr/bin/env node\nprocess.stderr.write("blocked by legacy hook\\n");\nprocess.exit(2);\n' - ); + const rawInput = JSON.stringify(input); + const result = runHook(input); + assert.strictEqual(result.code, 0, 'Expected safe file edit to pass'); + assert.strictEqual(result.stdout, rawInput, 'Expected exact raw JSON passthrough'); + assert.strictEqual(result.stderr, '', 'Expected no stderr for safe edits'); + }) + ) + passed++; + else failed++; + if ( + test('blocks truncated protected config payloads instead of failing open', () => { const rawInput = JSON.stringify({ tool_name: 'Write', tool_input: { file_path: '.eslintrc.js', - content: 'module.exports = {};' + content: 'x'.repeat(1024 * 1024 + 2048) } }); - const result = runCustomHook(pluginRoot, 'pre:legacy-block', 'scripts/hooks/legacy-block.js', rawInput); - assert.strictEqual(result.code, 2, 'Expected failing legacy hook exit code to propagate'); - assert.strictEqual(result.stdout, '', 'Expected failing legacy hook to avoid raw passthrough'); - assert.ok(result.stderr.includes('blocked by legacy hook'), `Expected legacy hook stderr, got: ${result.stderr}`); - } finally { + const result = runHook(rawInput); + assert.strictEqual(result.code, 2, 'Expected truncated protected payload to be blocked'); + assert.strictEqual(result.stdout, '', 'Blocked truncated payload should not echo raw input'); + assert.ok(result.stderr.includes('Hook input exceeded 1048576 bytes'), `Expected size warning, got: ${result.stderr}`); + assert.ok(result.stderr.includes('truncated payload'), `Expected truncated payload warning, got: ${result.stderr}`); + }) + ) + passed++; + else failed++; + + if ( + test('allows first-time creation of a protected config file', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-')); try { - fs.rmSync(pluginRoot, { recursive: true, force: true }); - } catch { - // best-effort cleanup + const absPath = path.join(tmpDir, 'eslint.config.mjs'); + const input = { + tool_name: 'Write', + tool_input: { + file_path: absPath, + content: 'export default [];' + } + }; + + const rawInput = JSON.stringify(input); + const result = runHook(input); + assert.strictEqual(result.code, 0, `Expected exit 0 for first-time creation, got ${result.code}; stderr: ${result.stderr}`); + assert.strictEqual(result.stdout, rawInput, 'Expected raw passthrough when creation is allowed'); + assert.strictEqual(result.stderr, '', `Expected no stderr for first-time creation, got: ${result.stderr}`); + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } } - } - })) passed++; else failed++; + }) + ) + passed++; + else failed++; + + if ( + test('allows first-time creation when the parent directory does not exist yet', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-')); + try { + // Path under a non-existent subdirectory — statSync returns ENOENT + // on the final segment, which should be treated as "does not exist" + // and allow the write. (Agent or CLI is expected to create parents + // during the Write itself; this hook does not need to.) + const absPath = path.join(tmpDir, 'no-such-parent', '.prettierrc'); + const input = { + tool_name: 'Write', + tool_input: { + file_path: absPath, + content: '{}' + } + }; + + const rawInput = JSON.stringify(input); + const result = runHook(input); + assert.strictEqual(result.code, 0, `Expected exit 0 for ENOENT path, got ${result.code}; stderr: ${result.stderr}`); + assert.strictEqual(result.stdout, rawInput, 'Expected raw passthrough when path does not exist'); + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } + }) + ) + passed++; + else failed++; + + if ( + test('blocks protected paths that exist as a dangling symlink', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-')); + try { + const missingTarget = path.join(tmpDir, 'nowhere.js'); + const linkPath = path.join(tmpDir, '.eslintrc.js'); + try { + fs.symlinkSync(missingTarget, linkPath); + } catch (err) { + // Windows without Developer Mode or certain sandboxes disallow + // symlinks. Skip cleanly rather than fail the suite. + if (err.code === 'EPERM' || err.code === 'EACCES') { + console.log(' (skipped: symlink creation not permitted here)'); + return; + } + throw err; + } + + const input = { + tool_name: 'Write', + tool_input: { + file_path: linkPath, + content: 'module.exports = {};' + } + }; + + const result = runHook(input); + assert.strictEqual(result.code, 2, `Expected exit 2 for dangling symlink, got ${result.code}; stderr: ${result.stderr}`); + assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input'); + assert.ok( + result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), + `Expected block message, got: ${result.stderr}` + ); + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } + }) + ) + passed++; + else failed++; + + if ( + test('still blocks writes to an existing protected config file', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-')); + try { + const absPath = path.join(tmpDir, '.eslintrc.js'); + fs.writeFileSync(absPath, 'module.exports = { rules: {} };'); + + const input = { + tool_name: 'Edit', + tool_input: { + file_path: absPath, + content: 'module.exports = { rules: { "no-console": "off" } };' + } + }; + + const result = runHook(input); + assert.strictEqual(result.code, 2, 'Expected exit 2 when modifying an existing protected config'); + assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input'); + assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`); + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } + }) + ) + passed++; + else failed++; + + if ( + test('legacy hooks do not echo raw input when they fail without stdout', () => { + const pluginRoot = path.join(__dirname, '..', `tmp-runner-plugin-${Date.now()}`); + const scriptDir = path.join(pluginRoot, 'scripts', 'hooks'); + const scriptPath = path.join(scriptDir, 'legacy-block.js'); + + try { + fs.mkdirSync(scriptDir, { recursive: true }); + fs.writeFileSync(scriptPath, '#!/usr/bin/env node\nprocess.stderr.write("blocked by legacy hook\\n");\nprocess.exit(2);\n'); + + const rawInput = JSON.stringify({ + tool_name: 'Write', + tool_input: { + file_path: '.eslintrc.js', + content: 'module.exports = {};' + } + }); + + const result = runCustomHook(pluginRoot, 'pre:legacy-block', 'scripts/hooks/legacy-block.js', rawInput); + assert.strictEqual(result.code, 2, 'Expected failing legacy hook exit code to propagate'); + assert.strictEqual(result.stdout, '', 'Expected failing legacy hook to avoid raw passthrough'); + assert.ok(result.stderr.includes('blocked by legacy hook'), `Expected legacy hook stderr, got: ${result.stderr}`); + } finally { + try { + fs.rmSync(pluginRoot, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } + }) + ) + passed++; + else failed++; console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/hooks/cost-tracker.test.js b/tests/hooks/cost-tracker.test.js index 4a1f6fef..9e578b19 100644 --- a/tests/hooks/cost-tracker.test.js +++ b/tests/hooks/cost-tracker.test.js @@ -35,6 +35,14 @@ function withTempHome(homeDir) { }; } +function writeTranscript(filePath, entries) { + fs.writeFileSync( + filePath, + entries.map(entry => JSON.stringify(entry)).join('\n') + '\n', + 'utf8' + ); +} + function runScript(input, envOverrides = {}) { const inputStr = typeof input === 'string' ? input : JSON.stringify(input); const result = spawnSync('node', [script], { @@ -64,12 +72,40 @@ function runTests() { assert.strictEqual(result.stdout, inputStr, 'Expected stdout to match original input'); }) ? passed++ : failed++); - // 2. Creates metrics file when given valid usage data - (test('creates metrics file when given valid usage data', () => { + // 2. Creates metrics file when given transcript usage data + (test('creates metrics file when given transcript usage data', () => { const tmpHome = makeTempDir(); + const transcriptPath = path.join(tmpHome, 'session.jsonl'); + writeTranscript(transcriptPath, [ + { type: 'user', message: { content: 'ignored' } }, + { + type: 'assistant', + message: { + model: 'claude-sonnet-4-20250514', + usage: { + input_tokens: 1000, + output_tokens: 500, + cache_creation_input_tokens: 200, + cache_read_input_tokens: 300, + }, + }, + }, + { notJsonShape: true }, + { + type: 'assistant', + message: { + model: 'claude-opus-4-20250514', + usage: { + input_tokens: 25, + output_tokens: 5, + }, + }, + }, + ]); + const input = { - model: 'claude-sonnet-4-20250514', - usage: { input_tokens: 1000, output_tokens: 500 }, + session_id: 'session-from-hook', + transcript_path: transcriptPath, }; const result = runScript(input, withTempHome(tmpHome)); assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); @@ -79,8 +115,13 @@ function runTests() { const content = fs.readFileSync(metricsFile, 'utf8').trim(); const row = JSON.parse(content); - assert.strictEqual(row.input_tokens, 1000, 'Expected input_tokens to be 1000'); - assert.strictEqual(row.output_tokens, 500, 'Expected output_tokens to be 500'); + assert.strictEqual(row.session_id, 'session-from-hook', 'Expected input session ID to be recorded'); + assert.strictEqual(row.transcript_path, transcriptPath, 'Expected transcript_path to be recorded'); + assert.strictEqual(row.model, 'claude-opus-4-20250514', 'Expected last assistant model to be recorded'); + assert.strictEqual(row.input_tokens, 1025, 'Expected input_tokens to be summed from transcript'); + assert.strictEqual(row.output_tokens, 505, 'Expected output_tokens to be summed from transcript'); + assert.strictEqual(row.cache_write_tokens, 200, 'Expected cache write tokens to be summed from transcript'); + assert.strictEqual(row.cache_read_tokens, 300, 'Expected cache read tokens to be summed from transcript'); assert.ok(row.timestamp, 'Expected timestamp to be present'); assert.ok(typeof row.estimated_cost_usd === 'number', 'Expected estimated_cost_usd to be a number'); assert.ok(row.estimated_cost_usd > 0, 'Expected estimated_cost_usd to be positive'); diff --git a/tests/hooks/pre-bash-dev-server-block.test.js b/tests/hooks/pre-bash-dev-server-block.test.js index 7ec978dd..4f075ddb 100644 --- a/tests/hooks/pre-bash-dev-server-block.test.js +++ b/tests/hooks/pre-bash-dev-server-block.test.js @@ -89,6 +89,110 @@ function runTests() { assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); }) ? passed++ : failed++); + // --- Subshell bypass regression (issue: dev server slipped past via $(), ``, ()) --- + + if (!isWindows) { + (test('blocks $(npm run dev) — command substitution', () => { + const result = runScript('$(npm run dev)'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + assert.ok(result.stderr.includes('BLOCKED'), 'expected BLOCKED in stderr'); + }) ? passed++ : failed++); + + (test('blocks `npm run dev` — backtick substitution', () => { + const result = runScript('`npm run dev`'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + }) ? passed++ : failed++); + + (test('blocks echo $(npm run dev) — substitution nested in argument', () => { + const result = runScript('echo $(npm run dev)'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + }) ? passed++ : failed++); + + (test('blocks (npm run dev) — plain subshell group', () => { + const result = runScript('(npm run dev)'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + }) ? passed++ : failed++); + + (test('blocks $(echo a; npm run dev) — substitution with sequenced segments', () => { + const result = runScript('$(echo a; npm run dev)'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + }) ? passed++ : failed++); + + (test('blocks (pnpm dev) — plain subshell group with pnpm', () => { + const result = runScript('(pnpm dev)'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + }) ? passed++ : failed++); + + (test('allows tmux launcher inside subshell wrapping (exit code 0)', () => { + const result = runScript('(tmux new-session -d -s dev "npm run dev")'); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + }) ? passed++ : failed++); + + (test('allows single-quoted "(npm run dev)" — literal string, not a subshell', () => { + const result = runScript("git commit -m '(npm run dev)'"); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + }) ? passed++ : failed++); + + (test('allows double-quoted "(npm run dev)" — literal in double quotes (bash does not subshell)', () => { + const result = runScript('echo "(npm run dev)"'); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + }) ? passed++ : failed++); + + (test("allows single-quoted '$(npm run dev)' — literal string, no substitution", () => { + const result = runScript("git commit -m '$(npm run dev) fix'"); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + }) ? passed++ : failed++); + } + + // --- Round 1 review fixes (Greptile + CodeRabbit on PR #1889) --- + + if (!isWindows) { + (test('blocks $(echo ")"; (npm run dev)) — quoted ) does not terminate $() early', () => { + const result = runScript('$(echo ")"; (npm run dev))'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + }) ? passed++ : failed++); + + (test('blocks (echo ")"; npm run dev) — quoted ) does not terminate (...) early', () => { + const result = runScript('(echo ")"; npm run dev)'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + }) ? passed++ : failed++); + + (test('allows $(echo "(npm run dev)") — () inside double-quoted substitution body is literal', () => { + const result = runScript('$(echo "(npm run dev)")'); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + }) ? passed++ : failed++); + + (test('blocks { npm run dev; } — brace group runs in current shell', () => { + const result = runScript('{ npm run dev; }'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + }) ? passed++ : failed++); + + (test('blocks echo hi && { npm run dev; } — brace group after &&', () => { + const result = runScript('echo hi && { npm run dev; }'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + }) ? passed++ : failed++); + + (test('allows {npm run dev} — bash requires space after { to form a group', () => { + const result = runScript('{npm run dev}'); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + }) ? passed++ : failed++); + + (test('blocks yarn run dev — yarn 1.x convention', () => { + const result = runScript('yarn run dev'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + }) ? passed++ : failed++); + + (test('blocks bun dev — bun bare form', () => { + const result = runScript('bun dev'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + }) ? passed++ : failed++); + + (test('blocks "$(npm run dev)" — double-quoted substitution still substitutes', () => { + const result = runScript('echo "$(npm run dev)"'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + }) ? passed++ : failed++); + } + // --- Edge cases --- (test('empty/invalid input passes through (exit code 0)', () => { diff --git a/tests/hooks/suggest-compact.test.js b/tests/hooks/suggest-compact.test.js index 217304b4..dc3c5c23 100644 --- a/tests/hooks/suggest-compact.test.js +++ b/tests/hooks/suggest-compact.test.js @@ -366,6 +366,66 @@ function runTests() { })) passed++; else failed++; + // ── hookSpecificOutput JSON on stdout ── + // Claude Code 2.1+ drops non-blocking PreToolUse stderr; the suggestion has + // to ride on stdout as { hookSpecificOutput: { additionalContext } } to reach + // the model. These tests pin that contract. + console.log('\nhookSpecificOutput stdout JSON:'); + + if (test('emits hookSpecificOutput.additionalContext on stdout at threshold', () => { + const { sessionId, counterFile, cleanup } = createCounterContext(); + cleanup(); + fs.writeFileSync(counterFile, '49'); + const result = runCompact({ CLAUDE_SESSION_ID: sessionId }); + assert.strictEqual(result.code, 0, 'Should exit 0'); + assert.ok(result.stdout.trim().length > 0, `Expected stdout payload at threshold. Got: "${result.stdout}"`); + const parsed = JSON.parse(result.stdout); + assert.strictEqual(parsed.hookSpecificOutput.hookEventName, 'PreToolUse', + `hookEventName should be PreToolUse. Got: ${JSON.stringify(parsed)}`); + assert.ok(parsed.hookSpecificOutput.additionalContext.includes('50 tool calls reached'), + `additionalContext should include threshold text. Got: ${parsed.hookSpecificOutput.additionalContext}`); + cleanup(); + })) passed++; + else failed++; + + if (test('emits hookSpecificOutput.additionalContext on stdout at +25 interval', () => { + const { sessionId, counterFile, cleanup } = createCounterContext(); + cleanup(); + // threshold=3, set counter to 27 → next run = 28 → 28-3=25 → interval hit + fs.writeFileSync(counterFile, '27'); + const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' }); + assert.strictEqual(result.code, 0, 'Should exit 0'); + assert.ok(result.stdout.trim().length > 0, `Expected stdout payload at interval. Got: "${result.stdout}"`); + const parsed = JSON.parse(result.stdout); + assert.strictEqual(parsed.hookSpecificOutput.hookEventName, 'PreToolUse'); + assert.ok(parsed.hookSpecificOutput.additionalContext.includes('28 tool calls'), + `additionalContext should include count. Got: ${parsed.hookSpecificOutput.additionalContext}`); + cleanup(); + })) passed++; + else failed++; + + if (test('emits no stdout below threshold (silent)', () => { + const { sessionId, cleanup } = createCounterContext(); + cleanup(); + const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '5' }); + assert.strictEqual(result.code, 0); + assert.strictEqual(result.stdout.trim(), '', + `Expected empty stdout below threshold. Got: "${result.stdout}"`); + cleanup(); + })) passed++; + else failed++; + + if (test('still writes [StrategicCompact] to stderr (debug log retained)', () => { + const { sessionId, counterFile, cleanup } = createCounterContext(); + cleanup(); + fs.writeFileSync(counterFile, '49'); + const result = runCompact({ CLAUDE_SESSION_ID: sessionId }); + assert.ok(result.stderr.includes('[StrategicCompact]'), + `stderr should retain [StrategicCompact] for debug log capture. Got: "${result.stderr}"`); + cleanup(); + })) passed++; + else failed++; + // ── Round 64: default session ID fallback ── console.log('\nDefault session ID fallback (Round 64):');