diff --git a/.kiro/skills/django-security/SKILL.md b/.kiro/skills/django-security/SKILL.md index 0049e6601..f5cd27d9e 100644 --- a/.kiro/skills/django-security/SKILL.md +++ b/.kiro/skills/django-security/SKILL.md @@ -36,12 +36,10 @@ SECURE_HSTS_SECONDS = 31536000 # 1 year SECURE_HSTS_INCLUDE_SUBDOMAINS = True SECURE_HSTS_PRELOAD = True SECURE_CONTENT_TYPE_NOSNIFF = True -SECURE_BROWSER_XSS_FILTER = True X_FRAME_OPTIONS = 'DENY' # HTTPS and Cookies SESSION_COOKIE_HTTPONLY = True -CSRF_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' CSRF_COOKIE_SAMESITE = 'Lax' @@ -311,7 +309,6 @@ def greet_user(username): ```python # settings.py SECURE_CONTENT_TYPE_NOSNIFF = True # Prevent MIME sniffing -SECURE_BROWSER_XSS_FILTER = True # Enable XSS filter X_FRAME_OPTIONS = 'DENY' # Prevent clickjacking # Custom middleware diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json index 8b196314a..4e3c024ec 100644 --- a/.opencode/package-lock.json +++ b/.opencode/package-lock.json @@ -8,6 +8,9 @@ "name": "ecc-universal", "version": "2.2.3", "license": "MIT", + "dependencies": { + "zod": "4.6.7" + }, "devDependencies": { "@opencode-ai/plugin": "^1.4.3", "@types/node": "^20.0.0", @@ -156,10 +159,9 @@ } }, "node_modules/zod": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", - "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", - "dev": true, + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/.opencode/package.json b/.opencode/package.json index 67ce27594..ec3e5d12d 100644 --- a/.opencode/package.json +++ b/.opencode/package.json @@ -56,6 +56,9 @@ "publishConfig": { "access": "public" }, + "dependencies": { + "zod": "4.6.7" + }, "peerDependencies": { "@opencode-ai/plugin": ">=1.0.0" }, @@ -66,5 +69,8 @@ }, "engines": { "node": ">=18.0.0" + }, + "overrides": { + "zod": "4.6.7" } } diff --git a/.opencode/tools/changed-files.ts b/.opencode/tools/changed-files.ts index 3ae000e1b..bf400cd32 100644 --- a/.opencode/tools/changed-files.ts +++ b/.opencode/tools/changed-files.ts @@ -1,4 +1,4 @@ -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { tool, type ToolDefinition } from "./tool.ts" import type { ChangeType, TreeNode } from "../plugins/lib/changed-files-store.ts" const INDICATORS: Record = { diff --git a/.opencode/tools/check-coverage.ts b/.opencode/tools/check-coverage.ts index 46f11cd82..85c7e0cf2 100644 --- a/.opencode/tools/check-coverage.ts +++ b/.opencode/tools/check-coverage.ts @@ -5,7 +5,7 @@ * Supports common coverage report formats. */ -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { tool, type ToolDefinition } from "./tool.ts" import * as path from "path" import * as fs from "fs" diff --git a/.opencode/tools/dependency-analyzer.ts b/.opencode/tools/dependency-analyzer.ts index 449cebb67..a77401454 100644 --- a/.opencode/tools/dependency-analyzer.ts +++ b/.opencode/tools/dependency-analyzer.ts @@ -5,7 +5,7 @@ * and unused dependencies. Supports multiple package managers. */ -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { tool, type ToolDefinition } from "./tool.ts" import * as path from "path" import * as fs from "fs" diff --git a/.opencode/tools/format-code.ts b/.opencode/tools/format-code.ts index 5c2d96d54..8be600047 100644 --- a/.opencode/tools/format-code.ts +++ b/.opencode/tools/format-code.ts @@ -6,7 +6,7 @@ * Supports cross-platform command generation. */ -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { tool, type ToolDefinition } from "./tool.ts" import * as path from "path" import * as fs from "fs" diff --git a/.opencode/tools/git-summary.ts b/.opencode/tools/git-summary.ts index b902f8e65..33f162d00 100644 --- a/.opencode/tools/git-summary.ts +++ b/.opencode/tools/git-summary.ts @@ -4,7 +4,7 @@ * Returns branch/status/log/diff details for the active repository. */ -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { tool, type ToolDefinition } from "./tool.ts" import { execFileSync } from "child_process" // Conservative subset of git's allowed ref-name characters. Rejects shell diff --git a/.opencode/tools/lint-check.ts b/.opencode/tools/lint-check.ts index 88b4a7435..5b16c18a7 100644 --- a/.opencode/tools/lint-check.ts +++ b/.opencode/tools/lint-check.ts @@ -5,7 +5,7 @@ * Supports cross-platform command generation and error handling. */ -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { tool, type ToolDefinition } from "./tool.ts" import * as path from "path" import * as fs from "fs" diff --git a/.opencode/tools/run-tests.ts b/.opencode/tools/run-tests.ts index aa8ce520d..3291531e5 100644 --- a/.opencode/tools/run-tests.ts +++ b/.opencode/tools/run-tests.ts @@ -5,7 +5,7 @@ * Automatically detects the package manager and test framework. */ -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { tool, type ToolDefinition } from "./tool.ts" import * as path from "path" import * as fs from "fs" diff --git a/.opencode/tools/security-audit.ts b/.opencode/tools/security-audit.ts index 59b76d9ea..0236acb21 100644 --- a/.opencode/tools/security-audit.ts +++ b/.opencode/tools/security-audit.ts @@ -8,7 +8,7 @@ * The regex patterns below are used to DETECT potential issues in user code. */ -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { tool, type ToolDefinition } from "./tool.ts" import * as path from "path" import * as fs from "fs" diff --git a/.opencode/tools/tool.ts b/.opencode/tools/tool.ts new file mode 100644 index 000000000..2477f6e62 --- /dev/null +++ b/.opencode/tools/tool.ts @@ -0,0 +1,10 @@ +import { z } from "zod" +import type { tool as OpenCodeTool } from "@opencode-ai/plugin/tool" + +export type { ToolDefinition } from "@opencode-ai/plugin/tool" + +// OpenCode's tool helper returns the definition unchanged and exposes Zod as schema. +// Keep the runtime import local so packed installs use the audited Zod version. +export const tool = Object.assign((definition: T): T => definition, { + schema: z, +}) as typeof OpenCodeTool diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a67bdf9b..764a58e63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - GateGuard gates NotebookEdit as a first-touch edit of its `notebook_path`; notebooks were never gated before. - GateGuard prior-search credit no longer counts a search that never saw the target. File-type filters (`rg -t`/`-T`/`--type`/`--type-not`, `fd -e`/`--extension`, the Grep tool's `type`), the Glob tool's pattern, shell glob operands, and git pathspecs now filter like include globs, so `rg -t py tokenizer src` or `Glob **/tokenizer.py` no longer credits `tokenizer.js`. A search confined to the target's own file name (`Glob **/tokenizer.js`, `find -name tokenizer.js`, Grep `glob: 'tokenizer.js'`) is a lookup and never credits it. PowerShell searches keep backslashes as path separators. - GateGuard no longer passes an edit as comment-only when its text contains U+2028, U+2029, or U+0085, which end a JavaScript or C# line comment. +- Claude Code home installs (`ecc install --target claude` and `install.sh`) now install under `CLAUDE_CONFIG_DIR` when it is set, matching the directory Claude Code reads its user-level config from. Previously ECC wrote to `~/.claude` regardless, so when `CLAUDE_CONFIG_DIR` redirected the config root the install never loaded ("installs, but nothing loads"); the settings.json, plugin.json, hook commands, bundle and install state were all stranded in `~/.claude` while Claude Code read an empty `CLAUDE_CONFIG_DIR`. The claude-home adapter now resolves its root the same way the runtime does (`CLAUDE_CONFIG_DIR || ~/.claude`), consistent with the opencode adapter and `resolveClaudePaths`. ## 2.2.3 - 2026-10-01 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 843771b15..6029fdc5e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,9 @@ Thanks for wanting to contribute! This repo is a community resource for Claude C - [Cross-Harness and Translations](#cross-harness-and-translations) - [Pull Request Process](#pull-request-process) - [Releases](#releases) +- [Guidelines](#guidelines) +- [File Naming](#file-naming) +- [Questions?](#questions) --- diff --git a/agents/opensource-forker.md b/agents/opensource-forker.md index 4c5d8cbb8..b2c07103b 100644 --- a/agents/opensource-forker.md +++ b/agents/opensource-forker.md @@ -145,13 +145,22 @@ JWT_SECRET=change-me-to-a-random-string ```bash cd TARGET_DIR git init +printf '%s\n' FORK_REPORT.md SANITIZATION_REPORT.md >> .git/info/exclude git add -A +git rm -q --cached --ignore-unmatch FORK_REPORT.md SANITIZATION_REPORT.md git commit -m "Initial open-source release Forked from private source. All secrets stripped, internal references replaced with configurable placeholders. See .env.example for configuration." ``` +This commit is provisional. The pipeline amends it after sanitizer fixes and packaging, so the +published history stays one commit (see the opensource-pipeline skill, Step 3). The reports never +go into it: `.git/info/exclude` is written before `git add -A`, so even a report already present in +the source project (copied by Step 2) stays untracked. A `.gitignore` rule outranks +`.git/info/exclude`, so a copied `!FORK_REPORT.md` would re-include it; `git rm --cached` drops +the reports from the index in that case. + ### Step 7: Generate Fork Report Create `FORK_REPORT.md` in the staging directory: diff --git a/agents/opensource-sanitizer.md b/agents/opensource-sanitizer.md index 36688a87a..c61e1de4a 100644 --- a/agents/opensource-sanitizer.md +++ b/agents/opensource-sanitizer.md @@ -135,10 +135,20 @@ cd PROJECT_DIR git log --oneline | wc -l # If > 1, history was not cleaned — FAIL -# Search history for potential secrets -git log -p | grep -iE '(password|secret|api.?key|token)' | head -20 +# Write the full history to a file outside the project, so Steps 1-3 never scan it +HISTORY="$(mktemp)" +git log -p --all --no-color > "$HISTORY" +echo "$HISTORY" + +# Broad keyword pass (WARNING — manual review) +grep -iE '(password|secret|api.?key|token)' "$HISTORY" | head -20 ``` +Steps 1-3 read the working tree, but the push sends commits. A secret fixed in a file but still in +a commit is published. Run every Step 1 pattern (CRITICAL) and the Step 3 home-directory patterns +over `$HISTORY` with the same tool you used for Steps 1-3. Any match is a FAIL, even when the +current file is clean. Delete `$HISTORY` when done. + ### Step 7: Unreadable Files (WARNING) Steps 1-3 only match text, so a credential sitting in a spreadsheet, a PDF or an embedded database diff --git a/commands/auto-update.md b/commands/auto-update.md index 54524f551..41e0d43e1 100644 --- a/commands/auto-update.md +++ b/commands/auto-update.md @@ -11,7 +11,7 @@ Update ECC from its upstream repo and regenerate the current context's managed i ```bash # Preview the update without mutating anything -ECC_ROOT="$(node -e "var r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=(process.env.CLAUDE_CONFIG_DIR||'').trim()||p.join(o.homedir(),'.claude');if(d==='~')d=o.homedir();else if(d.charAt(0)==='~'&&(d.charAt(1)==='/'||d.charCodeAt(1)===92))d=p.join(o.homedir(),d.slice(2));d=p.resolve(d);function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot({probe:p.join('scripts','auto-update.js')})}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i **前提条件**: このガイドは[ECC 簡潔ガイド](./the-shortform-guide.md)を基に構成されています。スキル、フック、サブエージェント、MCP、プラグインのセットアップがまだの場合は、先にそちらをお読みください。 -![Reference to Shorthand Guide](./assets/images/longform/02-shortform-reference.png) +![Reference to Shorthand Guide](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/longform/02-shortform-reference.png) *簡潔ガイド - まずこちらを読んでください* 簡潔ガイドでは、基礎的なセットアップをカバーしました:スキルとコマンド、フック、サブエージェント、MCP、プラグイン、そして効果的なClaude Codeワークフローのバックボーンとなる設定パターン。それはセットアップガイドであり、基盤インフラでした。 @@ -39,7 +39,7 @@ MCPを実際に使用せずにCLIをMCPのように機能させるには(そ セッション間でメモリを共有するには、進捗を要約してチェックインし、`.claude`フォルダの`.tmp`ファイルに保存してセッション終了まで追記するスキルまたはコマンドが最善策です。翌日にはそれをコンテキストとして使用し、中断した箇所から再開できます。古いコンテキストが新しい作業を汚染しないよう、各セッションごとに新しいファイルを作成してください。 -![Session Storage File Tree](./assets/images/longform/03-session-storage.png) +![Session Storage File Tree](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/longform/03-session-storage.png) *セッションストレージの例 -> * Claudeが現在の状態を要約するファイルを作成します。レビューし、必要に応じて編集を依頼し、新しく開始。新しい会話では、ファイルパスを提供するだけです。コンテキスト制限に達して複雑な作業を継続する必要がある場合に特に便利です。これらのファイルには以下を含めるべきです: @@ -110,7 +110,7 @@ alias claude-research='claude --system-prompt "$(cat ~/.claude/contexts/research **モデル選択クイックリファレンス:** -![Model Selection Table](./assets/images/longform/04-model-selection.png) +![Model Selection Table](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/longform/04-model-selection.png) *さまざまな一般的タスクにおけるサブエージェントの仮想セットアップと選択理由* | タスクタイプ | モデル | 理由 | @@ -128,14 +128,14 @@ alias claude-research='claude --system-prompt "$(cat ~/.claude/contexts/research **価格リファレンス:** -![Claude Model Pricing](./assets/images/longform/05-pricing-table.png) +![Claude Model Pricing](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/longform/05-pricing-table.png) *出典: * **ツール固有の最適化:** grepをmgrepに置き換え — 従来のgrepやripgrepと比較して平均約50%のトークン削減: -![mgrep Benchmark](./assets/images/longform/06-mgrep-benchmark.png) +![mgrep Benchmark](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/longform/06-mgrep-benchmark.png) *50タスクのベンチマークで、mgrep + Claude Codeはgrepベースのワークフローと同等以上の品質で約2倍少ないトークンを使用。出典:@mixedbread-aiによるmgrep* **モジュラーコードベースの利点:** @@ -181,7 +181,7 @@ pass^k: k回の試行すべてが成功する必要がある **任意のターミナル数について:** -![Boris on Parallel Terminals](./assets/images/longform/07-boris-parallel.png) +![Boris on Parallel Terminals](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/longform/07-boris-parallel.png) *Boris(Anthropic)が複数のClaudeインスタンスの実行について* Borisは並列化のヒントを持っています。ローカルで5つ、上流で5つのClaudeインスタンスを実行するようなことを提案しています。任意のターミナル数の設定は推奨しません。ターミナルの追加は本当の必要性から生まれるべきです。 @@ -202,7 +202,7 @@ cd ../project-feature-a && claude インスタンスのスケーリングを開始し、複数のClaudeインスタンスが互いに重複するコードで作業する場合、gitワークツリーを使用し、各インスタンスに非常に明確な計画を持つことが不可欠です。`/rename <名前>`を使用してすべてのチャットに名前を付けてください。 -![Two Terminal Setup](./assets/images/longform/08-two-terminals.png) +![Two Terminal Setup](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/longform/08-two-terminals.png) *初期セットアップ:左ターミナルでコーディング、右ターミナルで質問 - /renameと/forkを使用* **カスケード方式:** @@ -314,7 +314,7 @@ alias q='cd ~/Desktop/projects' ## マイルストーン -![25k+ GitHub Stars](./assets/images/longform/09-25k-stars.png) +![25k+ GitHub Stars](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/longform/09-25k-stars.png) *1週間足らずでGitHub 25,000+スター* --- diff --git a/docs/ja-JP/the-security-guide.md b/docs/ja-JP/the-security-guide.md index b5d1da681..a4df13a42 100644 --- a/docs/ja-JP/the-security-guide.md +++ b/docs/ja-JP/the-security-guide.md @@ -18,7 +18,7 @@ _ecc / research / security_ ### 攻撃チェーンと関与するノード / コンポーネント -![Attack Chain Diagram](./assets/images/security/attack-chain.png) +![Attack Chain Diagram](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/security/attack-chain.png) 例えば、あなたのエージェントがゲートウェイレイヤーを介してWhatsAppに接続されているとします。攻撃者があなたのWhatsApp番号を知っています。既存のジェイルブレイクを使ってプロンプトインジェクションを試みます。チャットにジェイルブレイクをスパムします。エージェントがメッセージを読み、指示として受け取ります。プライベート情報を明かすレスポンスを実行します。エージェントがroot権限、広範なファイルシステムアクセス、または有用な認証情報を持っている場合、あなたは侵害されています。 diff --git a/docs/ja-JP/the-shortform-guide.md b/docs/ja-JP/the-shortform-guide.md index 0f3ea21aa..ae536ca4a 100644 --- a/docs/ja-JP/the-shortform-guide.md +++ b/docs/ja-JP/the-shortform-guide.md @@ -1,6 +1,6 @@ # ECC 簡潔ガイド -![Header: Anthropic Hackathon Winner - Tips & Tricks for Claude Code](./assets/images/shortform/00-header.png) +![Header: Anthropic Hackathon Winner - Tips & Tricks for Claude Code](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/shortform/00-header.png) --- @@ -16,7 +16,7 @@ Opus 4.5での長いコーディングセッション後にデッドコードや散らかった.mdファイルを整理したい?`/refactor-clean`を実行。テストが必要?`/tdd`、`/e2e`、`/test-coverage`。これらのスラッシュエントリーは便利ですが、真に持続的な単位は基盤となるスキルです。スキルにはコードマップも含められます — コンテキストを探索に消費せずにClaude がコードベースを素早くナビゲートする方法です。 -![Terminal showing chained commands](./assets/images/shortform/02-chaining-commands.jpeg) +![Terminal showing chained commands](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/shortform/02-chaining-commands.jpeg) *コマンドの連鎖実行* ECCは依然として`commands/`レイヤーを提供していますが、マイグレーション中のレガシースラッシュエントリー互換性と考えるのが最適です。持続的なロジックはスキルに置くべきです。 @@ -66,7 +66,7 @@ ECCは依然として`commands/`レイヤーを提供していますが、マイ } ``` -![PostToolUse hook feedback](./assets/images/shortform/03-posttooluse-hook.png) +![PostToolUse hook feedback](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/shortform/03-posttooluse-hook.png) *PostToolUseフック実行中のClaude Codeでのフィードバック例* **プロヒント:** JSONを手動で書く代わりに`hookify`プラグインを使ってフックを会話的に作成できます。`/hookify`を実行して欲しいものを説明してください。 @@ -129,7 +129,7 @@ MCPはClaudeを外部サービスに直接接続します。APIの置き換え **例:** Supabase MCPにより、Claudeはコピー&ペーストなしで特定のデータを取得し、上流で直接SQLを実行できます。データベース、デプロイメントプラットフォームなども同様です。 -![Supabase MCP listing tables](./assets/images/shortform/04-supabase-mcp.jpeg) +![Supabase MCP listing tables](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/shortform/04-supabase-mcp.jpeg) *Supabase MCPがpublicスキーマ内のテーブルを一覧表示している例* **Claude内のChrome:** Claudeがブラウザを自律的に制御する組み込みプラグインMCP — クリックして動作を確認できます。 @@ -138,7 +138,7 @@ MCPはClaudeを外部サービスに直接接続します。APIの置き換え MCPは厳選してください。すべてのMCPをユーザー設定に入れていますが、**未使用のものはすべて無効化**しています。`/plugins`に移動してスクロールするか、`/mcp`を実行してください。 -![/plugins interface](./assets/images/shortform/05-plugins-interface.jpeg) +![/plugins interface](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/shortform/05-plugins-interface.jpeg) */pluginsを使用してMCPのインストール状況とステータスを確認* 圧縮前の200kコンテキストウィンドウも、有効なツールが多すぎると70kにしかならない場合があります。パフォーマンスが大幅に低下します。 @@ -168,7 +168,7 @@ claude plugin marketplace add https://github.com/mixedbread-ai/mgrep # Claudeを開き、/plugins を実行、新しいマーケットプレイスを見つけてインストール ``` -![Marketplaces tab showing mgrep](./assets/images/shortform/06-marketplaces-mgrep.jpeg) +![Marketplaces tab showing mgrep](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/shortform/06-marketplaces-mgrep.jpeg) *新しくインストールされたMixedbread-Grepマーケットプレイスの表示* **LSPプラグイン**は、エディタ外でClaude Codeを頻繁に使用する場合に特に便利です。Language Server Protocolにより、IDEを開かずにClaudeにリアルタイムの型チェック、定義ジャンプ、インテリジェント補完を提供します。 @@ -237,7 +237,7 @@ mgrep --web "Next.js 15 app router changes" # Web検索 GitHub ActionsでPRにコードレビューを設定。設定すればClaudeがPRを自動的にレビューできます。 -![Claude bot approving a PR](./assets/images/shortform/08-github-pr-review.jpeg) +![Claude bot approving a PR](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/shortform/08-github-pr-review.jpeg) *Claudeがバグ修正PRを承認* ### サンドボックス @@ -262,7 +262,7 @@ GitHub ActionsでPRにコードレビューを設定。設定すればClaudeがP - **最小限のリソース使用** — 重い操作中にClaudeとRAM/CPUを競合しない。Opus実行時に重要 - **Vimモード** — お好みならフルVimキーバインド -![Zed Editor with custom commands](./assets/images/shortform/09-zed-editor.jpeg) +![Zed Editor with custom commands](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/shortform/09-zed-editor.jpeg) *CMD+Shift+Rでカスタムコマンドドロップダウンを表示するZedエディタ。右下にフォローモードが牛眼として表示。* **エディタに依存しないヒント:** @@ -277,7 +277,7 @@ GitHub ActionsでPRにコードレビューを設定。設定すればClaudeがP これも実用的な選択肢でClaude Codeとうまく連携します。`\ide`でLSP機能を有効にしたターミナル形式(プラグインでやや冗長になりました)、またはエディタにより統合されたマッチするUIの拡張機能を選択できます。 -![VS Code Claude Code Extension](./assets/images/shortform/10-vscode-extension.jpeg) +![VS Code Claude Code Extension](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/shortform/10-vscode-extension.jpeg) *VS Code拡張機能はClaude CodeのネイティブグラフィカルインターフェースをIDE内に直接統合して提供。* --- @@ -361,7 +361,7 @@ mgrep@Mixedbread-Grep # より良い検索 ユーザー、ディレクトリ、ダーティインジケーター付きgitブランチ、残りコンテキスト%、モデル、時間、Todoカウントを表示: -![Custom status line](./assets/images/shortform/11-statusline.jpeg) +![Custom status line](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/shortform/11-statusline.jpeg) *Macルートディレクトリでのステータスライン例* ``` diff --git a/docs/ko-KR/rules/git-workflow.md b/docs/ko-KR/rules/git-workflow.md index dbf30ee3e..780fb757f 100644 --- a/docs/ko-KR/rules/git-workflow.md +++ b/docs/ko-KR/rules/git-workflow.md @@ -21,4 +21,4 @@ PR을 만들 때: 5. 새 브랜치인 경우 `-u` 플래그와 함께 push > git 작업 전 전체 개발 프로세스(계획, TDD, 코드 리뷰)는 -> [development-workflow.md](./development-workflow.md)를 참고하세요. +> [development-workflow.md](https://github.com/affaan-m/ECC/blob/main/rules/common/development-workflow.md)를 참고하세요. diff --git a/docs/pt-BR/rules/git-workflow.md b/docs/pt-BR/rules/git-workflow.md index 5b75622b1..d236a9f52 100644 --- a/docs/pt-BR/rules/git-workflow.md +++ b/docs/pt-BR/rules/git-workflow.md @@ -21,4 +21,4 @@ Ao criar PRs: 5. Fazer push com a flag `-u` se for uma nova branch > Para o processo de desenvolvimento completo (planejamento, TDD, revisão de código) antes de operações git, -> veja [development-workflow.md](./development-workflow.md). +> veja [development-workflow.md](https://github.com/affaan-m/ECC/blob/main/rules/common/development-workflow.md). diff --git a/docs/zh-CN/CONTRIBUTING.md b/docs/zh-CN/CONTRIBUTING.md index dcc63ab2f..016b6cbde 100644 --- a/docs/zh-CN/CONTRIBUTING.md +++ b/docs/zh-CN/CONTRIBUTING.md @@ -309,7 +309,7 @@ commands/your-command.md ### 命令模板 -```markdown +````markdown --- description: 在 /help 中显示的简要描述 --- @@ -338,7 +338,7 @@ description: 在 /help 中显示的简要描述 用户将收到的内容。 -``` +```` ### 命令示例 diff --git a/docs/zh-CN/commands/santa-loop.md b/docs/zh-CN/commands/santa-loop.md index adafef45d..14e176587 100644 --- a/docs/zh-CN/commands/santa-loop.md +++ b/docs/zh-CN/commands/santa-loop.md @@ -93,8 +93,10 @@ EOF **Codex CLI**(如果已安装) +Codex 使用已配置的模型或 CLI 默认值。在 Codex 配置中设置 `model`,即可选择账号支持的评审模型。 + ```bash -codex exec --sandbox read-only -m gpt-5.4 -C "$(pwd)" - < "$PROMPT_FILE" +codex exec --sandbox read-only -C "$(pwd)" - < "$PROMPT_FILE" rm -f "$PROMPT_FILE" ``` @@ -171,8 +173,8 @@ Result: [PUSHED / ESCALATED TO USER] ## 备注 * 评审者 A(Claude Opus)始终运行——无论工具如何,保证至少有一个强大的评审者。 -* 模型多样性是评审者 B 的目标。GPT-5.4 或 Gemini 2.5 Pro 提供真正的独立性——不同的训练数据、不同的偏见、不同的盲点。仅 Claude 的回退通过上下文隔离仍然提供价值,但失去了模型多样性。 -* 使用最强可用模型:Opus 用于评审者 A,GPT-5.4 或 Gemini 2.5 Pro 用于评审者 B。 +* 模型多样性是评审者 B 的目标。记录所选 backend 报告的实际模型和 provider。仅 Claude 的回退仍提供上下文隔离,但失去了模型多样性。 +* 评审者 A 使用 Opus;评审者 B 的 Codex 使用已配置的模型或 CLI 默认值。 * 外部评审者使用 `--sandbox read-only`(Codex)运行,以防止审查期间仓库发生变异。 * 每轮使用全新的评审者可以防止先前发现导致的锚定偏差。 * 评分标准是最重要的输入。如果评审者盖章通过或标记主观风格问题,请收紧评分标准。 diff --git a/docs/zh-CN/skills/brand-voice/SKILL.md b/docs/zh-CN/skills/brand-voice/SKILL.md index 717904da4..48678daad 100644 --- a/docs/zh-CN/skills/brand-voice/SKILL.md +++ b/docs/zh-CN/skills/brand-voice/SKILL.md @@ -48,7 +48,7 @@ origin: ECC ## 输出约定 -生成一个可复用的`VOICE PROFILE`代码块,供下游技能直接调用。使用[references/voice-profile-schema.md](references/voice-profile-schema.md)中的架构。 +生成一个可复用的`VOICE PROFILE`代码块,供下游技能直接调用。使用[references/voice-profile-schema.md](https://github.com/affaan-m/ECC/blob/main/skills/brand-voice/references/voice-profile-schema.md)中的架构。 保持档案结构化且足够简短,以便在会话上下文中复用。重点不是文学批评,而是操作复用。 diff --git a/hooks/codex-hooks.json b/hooks/codex-hooks.json index a88307708..202814342 100644 --- a/hooks/codex-hooks.json +++ b/hooks/codex-hooks.json @@ -7,7 +7,7 @@ "hooks": [ { "type": "command", - "command": "node -e \"if(!process.env.PLUGIN_ROOT)throw new Error('Missing Codex PLUGIN_ROOT');process.env.CLAUDE_PLUGIN_ROOT=process.env.PLUGIN_ROOT;const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=(process.env.CLAUDE_CONFIG_DIR||'').trim()||p.join(o.homedir(),'.claude');if(d==='~')d=o.homedir();else if(d.charAt(0)==='~'&&(d.charAt(1)==='/'||d.charCodeAt(1)===92))d=p.join(o.homedir(),d.slice(2));d=p.resolve(d);function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i=18" } }, - "node_modules/@ai-sdk/provider": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", - "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "json-schema": "^0.4.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", @@ -337,136 +325,39 @@ "dev": true, "license": "MIT" }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", - "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", - "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", - "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", - "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", - "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", - "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@opencode-ai/plugin": { - "version": "1.18.34", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.34.tgz", - "integrity": "sha512-bC1EBL/QJ6LpC7liKZXr5JA+oa9qDAwTzpEgPxPsx1pbIUdhKOqg3/yoPEQgoa4YdO3hsgCWNFmbDE/DeOl/Ug==", + "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": { - "@ai-sdk/provider": "3.0.8", - "@opencode-ai/sdk": "1.18.34", - "effect": "4.0.0-beta.83", + "@opencode-ai/sdk": "1.4.3", "zod": "4.1.8" }, "peerDependencies": { - "@opentui/core": ">=0.4.5", - "@opentui/keymap": ">=0.4.5", - "@opentui/solid": ">=0.4.5" + "@opentui/core": ">=0.1.97", + "@opentui/solid": ">=0.1.97" }, "peerDependenciesMeta": { "@opentui/core": { "optional": true }, - "@opentui/keymap": { - "optional": true - }, "@opentui/solid": { "optional": true } } }, "node_modules/@opencode-ai/sdk": { - "version": "1.18.34", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.34.tgz", - "integrity": "sha512-R9QWUpcngtdQ6FCixz7NC6n1WsSho6CEuRUzYHz/CzZcp3miSklDzXkm0oDwXVCnuEu+ANoeM0f6RDp51kOMzQ==", + "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", "dependencies": { "cross-spawn": "7.0.6" } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -896,17 +787,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -921,25 +801,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/effect": { - "version": "4.0.0-beta.83", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", - "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.8.0", - "find-my-way-ts": "^0.1.6", - "ini": "^7.0.0", - "kubernetes-types": "^1.30.0", - "msgpackr": "^2.0.1", - "multipasta": "^0.2.7", - "toml": "^4.1.1", - "uuid": "^14.0.0", - "yaml": "^2.9.0" - } - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1161,29 +1022,6 @@ "node": ">=0.10.0" } }, - "node_modules/fast-check": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", - "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT", - "dependencies": { - "pure-rand": "^8.0.0" - }, - "engines": { - "node": ">=12.17.0" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1205,9 +1043,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "funding": [ { "type": "github", @@ -1248,13 +1086,6 @@ "flat-cache": "^6.1.23" } }, - "node_modules/find-my-way-ts": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", - "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", - "dev": true, - "license": "MIT" - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1591,13 +1422,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "dev": true, - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -1665,13 +1489,6 @@ "@keyv/serialize": "^1.1.1" } }, - "node_modules/kubernetes-types": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", - "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2422,46 +2239,6 @@ "dev": true, "license": "MIT" }, - "node_modules/msgpackr": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", - "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.4" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", - "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" - } - }, - "node_modules/multipasta": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", - "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", - "dev": true, - "license": "MIT" - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2469,22 +2246,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2634,23 +2395,6 @@ "node": ">=6" } }, - "node_modules/pure-rand": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", - "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, "node_modules/qified": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", @@ -2865,16 +2609,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/toml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", - "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -2926,20 +2660,6 @@ "punycode": "^2.1.0" } }, - "node_modules/uuid": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", - "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -2991,22 +2711,6 @@ "node": ">=10" } }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -3088,10 +2792,9 @@ } }, "node_modules/zod": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", - "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", - "dev": true, + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index f30693419..f1c6b9075 100644 --- a/package.json +++ b/package.json @@ -545,7 +545,8 @@ "@iarna/toml": "2.2.5", "ajv": "8.20.0", "js-yaml": "4.3.2", - "sql.js": "1.14.2" + "sql.js": "1.14.2", + "zod": "4.6.7" }, "pi": { "extensions": [ @@ -560,7 +561,7 @@ }, "devDependencies": { "@eslint/js": "9.39.2", - "@opencode-ai/plugin": "1.18.34", + "@opencode-ai/plugin": "1.4.3", "@types/node": "26.6.4", "c8": "11.0.0", "eslint": "10.11.0", @@ -572,22 +573,24 @@ "node": ">=18" }, "overrides": { - "fast-uri": "3.1.8", + "fast-uri": "3.1.9", "markdown-it": "14.3.1", "js-yaml": "4.3.2", "@humanfs/node": "0.16.8", "brace-expansion": "5.0.12", "katex": "0.18.2", - "smol-toml": "1.9.1" + "smol-toml": "1.9.1", + "zod": "4.6.7" }, "resolutions": { - "fast-uri": "3.1.8", + "fast-uri": "3.1.9", "markdown-it": "14.3.1", "js-yaml": "4.3.2", "@humanfs/node": "0.16.8", "brace-expansion": "5.0.12", "katex": "0.18.2", - "smol-toml": "1.9.1" + "smol-toml": "1.9.1", + "zod": "4.6.7" }, "packageManager": "yarn@4.9.2+sha512.1fc009bc09d13cfd0e19efa44cbfc2b9cf6ca61482725eb35bbc5e257e093ebf4130db6dfe15d604ff4b79efd8e1e8e99b25fa7d0a6197c9f9826358d4d65c3c" } diff --git a/pi/core/skills/django-security/SKILL.md b/pi/core/skills/django-security/SKILL.md index 9e1fb25a0..9f25cc764 100644 --- a/pi/core/skills/django-security/SKILL.md +++ b/pi/core/skills/django-security/SKILL.md @@ -37,12 +37,10 @@ SECURE_HSTS_SECONDS = 31536000 # 1 year SECURE_HSTS_INCLUDE_SUBDOMAINS = True SECURE_HSTS_PRELOAD = True SECURE_CONTENT_TYPE_NOSNIFF = True -SECURE_BROWSER_XSS_FILTER = True X_FRAME_OPTIONS = 'DENY' # HTTPS and Cookies SESSION_COOKIE_HTTPONLY = True -CSRF_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' CSRF_COOKIE_SAMESITE = 'Lax' @@ -312,7 +310,6 @@ def greet_user(username): ```python # settings.py SECURE_CONTENT_TYPE_NOSNIFF = True # Prevent MIME sniffing -SECURE_BROWSER_XSS_FILTER = True # Enable XSS filter X_FRAME_OPTIONS = 'DENY' # Prevent clickjacking # Custom middleware @@ -338,7 +335,7 @@ class SecurityHeaderMiddleware: ```python # settings.py - CSRF is enabled by default CSRF_COOKIE_SECURE = True # Only send over HTTPS -CSRF_COOKIE_HTTPONLY = True # Prevent JavaScript access +CSRF_COOKIE_HTTPONLY = False # False so AJAX can read csrf token from document.cookie; SESSION_COOKIE_HTTPONLY remains True CSRF_COOKIE_SAMESITE = 'Lax' # Prevent CSRF in some cases CSRF_TRUSTED_ORIGINS = ['https://example.com'] # Trusted domains diff --git a/pi/core/skills/fastapi-patterns/SKILL.md b/pi/core/skills/fastapi-patterns/SKILL.md index 6cff4479d..b4a0a5b58 100644 --- a/pi/core/skills/fastapi-patterns/SKILL.md +++ b/pi/core/skills/fastapi-patterns/SKILL.md @@ -229,7 +229,7 @@ ActiveUserDep = Annotated[User, Depends(get_current_active_user)] ```python # app/routers/users.py from typing import Annotated -from fastapi import APIRouter, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi.security import OAuth2PasswordRequestForm from app.dependencies import ActiveUserDep, DbDep diff --git a/pi/core/skills/laravel-security/SKILL.md b/pi/core/skills/laravel-security/SKILL.md index f039fdfdd..dd65c7e1f 100644 --- a/pi/core/skills/laravel-security/SKILL.md +++ b/pi/core/skills/laravel-security/SKILL.md @@ -588,7 +588,7 @@ final class StorePostRequest extends FormRequest public function rules(): array { return [ - 'title' => ['required', 'string', 'max:255', 'sanitize_html'], + 'title' => ['required', 'string', 'max:255'], 'content' => ['required', 'string', 'max:10000'], 'image' => [ 'required', diff --git a/pi/core/skills/perl-security/SKILL.md b/pi/core/skills/perl-security/SKILL.md index 7bb7e470f..df4dddce5 100644 --- a/pi/core/skills/perl-security/SKILL.md +++ b/pi/core/skills/perl-security/SKILL.md @@ -412,10 +412,6 @@ Always encode output for its context: `HTML::Entities::encode_entities()` for HT severity = 3 theme = security + core -# Require three-arg open -[InputOutput::RequireThreeArgOpen] -severity = 5 - # Require checked system calls [InputOutput::RequireCheckedSyscalls] functions = :builtins @@ -429,10 +425,6 @@ severity = 5 [InputOutput::ProhibitBacktickOperators] severity = 4 -# Require taint checking in CGI -[Modules::RequireTaintChecking] -severity = 5 - # Prohibit two-arg open [InputOutput::ProhibitTwoArgOpen] severity = 5 diff --git a/pi/core/skills/python-testing/SKILL.md b/pi/core/skills/python-testing/SKILL.md index ddfcc0abc..8e68a084a 100644 --- a/pi/core/skills/python-testing/SKILL.md +++ b/pi/core/skills/python-testing/SKILL.md @@ -224,7 +224,7 @@ def test_without_fixture_call(): assert Config.get_setting("debug") is False ``` -### Conftest.py for Shared Fixtures +### Conftest.py for Shared Fixtures (Flask) ```python # tests/conftest.py @@ -484,15 +484,23 @@ async def test_async_with_fixture(async_client): assert response.status_code == 200 ``` -### Async Fixture +### Async Fixture (FastAPI) + +`ASGITransport` does not run lifespan events. Install `asgi-lifespan` and wrap the client in `LifespanManager` when the app uses startup or shutdown handlers. See [FastAPI's async testing guidance](https://github.com/fastapi/fastapi/blob/master/docs/en/docs/advanced/async-tests.md). ```python -@pytest.fixture +import pytest +import pytest_asyncio +from asgi_lifespan import LifespanManager +from httpx import ASGITransport, AsyncClient + +@pytest_asyncio.fixture async def async_client(): - """Async fixture providing async test client.""" - app = create_app() - async with app.test_client() as client: - yield client + """Async fixture providing async test client with lifespan events.""" + app = create_app() # Returns a FastAPI application + async with LifespanManager(app) as manager: + async with AsyncClient(transport=ASGITransport(app=manager.app), base_url="http://test") as client: + yield client @pytest.mark.asyncio async def test_api_endpoint(async_client): @@ -661,7 +669,7 @@ class TestUserService: ## Common Patterns -### Testing API Endpoints (FastAPI/Flask) +### Testing API Endpoints (Flask) ```python @pytest.fixture diff --git a/scripts/lib/inspection.js b/scripts/lib/inspection.js index e6f2cdf33..6b142b56d 100644 --- a/scripts/lib/inspection.js +++ b/scripts/lib/inspection.js @@ -21,8 +21,15 @@ function normalizeFailureReason(reason) { .replace(/\d{4}-\d{2}-\d{2}[t ]\d{2}:\d{2}:\d{2}[.\dz]*/g, '') // Strip UUIDs (already lowercased) .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, '') - // Strip file paths - .replace(/\/[\w./-]+/g, '') + // Quoted Windows paths preserve spaces and apostrophes inside path components. + // A closing quote must be followed by a delimiter, not another name character. + .replace(/(["'])(?:[a-z]:[\\/]|\\\\)[^\r\n]*?\1(?=[:\s),;.!?]|$)/gu, '$1$1') + // Directories may contain spaces, but trailing whitespace separates diagnostic prose. + // Unquoted spaced leaves allow one final dotted token; longer prose stays diagnostic. + // Quote paths with multiple spaces in the final filename to remove ambiguity. + .replace(/(?:\b[a-z]:|\\)[\\/](?:[^\\/:*?"<>|\r\n]*[^\s\\/:*?"<>|][\\/])*[^\s\\/:*?"<>|(),;]+(?: [^\s\\/:*?"<>|]+\.[^\s\\/:*?"<>|]+(?=:))?[\\/]*/gu, '') + // Strip remaining file paths + .replace(/(?:\b[a-z]:)?[\\/][\w./\\-]+/g, '') // Collapse whitespace .replace(/\s+/g, ' ') .trim(); @@ -60,6 +67,14 @@ function groupFailures(skillRuns) { return groups; } +function compareTimestampsDescending(a, b) { + const timeA = Date.parse(a); + const timeB = Date.parse(b); + if (Number.isNaN(timeA)) return Number.isNaN(timeB) ? 0 : 1; + if (Number.isNaN(timeB)) return -1; + return timeB - timeA; +} + /** * Detect recurring failure patterns from skill runs. * @@ -79,7 +94,7 @@ function detectPatterns(skillRuns, options = {}) { } const sortedRuns = [...group.runs].sort( - (a, b) => (b.createdAt || '').localeCompare(a.createdAt || '') + (a, b) => compareTimestampsDescending(a.createdAt, b.createdAt) ); const firstSeen = sortedRuns[sortedRuns.length - 1].createdAt || null; @@ -106,7 +121,7 @@ function detectPatterns(skillRuns, options = {}) { // Sort by count descending, then by lastSeen descending return patterns.sort((a, b) => { if (b.count !== a.count) return b.count - a.count; - return (b.lastSeen || '').localeCompare(a.lastSeen || ''); + return compareTimestampsDescending(a.lastSeen, b.lastSeen); }); } diff --git a/scripts/lib/install-executor.js b/scripts/lib/install-executor.js index 72eac5e8a..845a9b3e8 100644 --- a/scripts/lib/install-executor.js +++ b/scripts/lib/install-executor.js @@ -269,7 +269,7 @@ function planClaudeStyleLegacyInstall(context, { adapterId, adapterRootInput, ru function planClaudeLegacyInstall(context) { return planClaudeStyleLegacyInstall(context, { adapterId: 'claude', - adapterRootInput: { homeDir: context.homeDir }, + adapterRootInput: { homeDir: context.homeDir, env: context.env }, rulesDir: context.claudeRulesDir || null }); } @@ -461,6 +461,7 @@ function createLegacyInstallPlan(options = {}) { sourceRoot, projectRoot, homeDir, + env: options.env || {}, languages: Array.isArray(options.languages) ? options.languages : [], claudeRulesDir: options.claudeRulesDir || process.env.CLAUDE_RULES_DIR || null }; diff --git a/scripts/lib/install-targets/claude-home.js b/scripts/lib/install-targets/claude-home.js index 3edc41249..c1a57c742 100644 --- a/scripts/lib/install-targets/claude-home.js +++ b/scripts/lib/install-targets/claude-home.js @@ -1,4 +1,6 @@ +const os = require('os'); const path = require('path'); +const { resolveInvocationEnvironment } = require('../invocation-environment'); const { HOME_INSTALL_EXCLUDED_SOURCE_PATHS, @@ -7,6 +9,7 @@ const { isForeignPlatformPath, normalizeRelativePath, planClaudeHooksOperations, + convertWindowsPathToWSL, } = require('./helpers'); const CLAUDE_ECC_NAMESPACE = 'ecc'; @@ -47,11 +50,52 @@ function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) { return null; } +/** + * Expand leading ~, ~/, ~\ against the home directory. + * Mirrors the expansion in resolveEccRoot to keep install and runtime roots in sync. + */ +function expandTilde(inputPath, homeDir) { + if (typeof inputPath !== 'string') return inputPath; + const trimmed = inputPath.trim(); + if (trimmed === '~') return homeDir; + if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return path.join(homeDir, trimmed.slice(2)); + } + return trimmed; +} + +// Claude Code loads its user-level config from CLAUDE_CONFIG_DIR when set, +// otherwise ~/.claude. Resolve the install root the same way the runtime does +// (see resolveClaudePaths/inventory and getDefaultClaudeAgentDataHome) so an +// install lands where Claude Code will actually read it; without this, ECC +// writes to ~/.claude and "installs, but nothing loads" whenever CLAUDE_CONFIG_DIR +// redirects the config root (common on WSL / dev-container / dotfile setups). +// On WSL, convert Windows paths (e.g., C:\Users\name\.claude) to WSL paths +// (/mnt/c/Users/name/.claude) so the installer writes to the correct location. +function resolveClaudeConfigRoot(options = {}) { + const environment = resolveInvocationEnvironment(options); + const configured = environment && environment.CLAUDE_CONFIG_DIR; + const homeDir = path.resolve(options.homeDir || os.homedir()); + if (typeof configured === 'string' && configured.trim() !== '') { + const normalized = configured.trim(); + const converted = convertWindowsPathToWSL(normalized); + // After WSL conversion, a blank result means the config dir was invalid. + // Fall back to the default Claude directory just like the runtime adapter does. + const expanded = expandTilde(converted, homeDir); + if (typeof expanded === 'string' && expanded.trim() === '') { + return path.join(homeDir, '.claude'); + } + return path.resolve(expanded); + } + return path.join(homeDir, '.claude'); +} + module.exports = createInstallTargetAdapter({ id: 'claude-home', target: 'claude', kind: 'home', rootSegments: ['.claude'], + resolveRoot: resolveClaudeConfigRoot, installStatePathSegments: ['ecc', 'install-state.json'], excludedSourcePaths: HOME_INSTALL_EXCLUDED_SOURCE_PATHS, nativeRootRelativePath: '.claude-plugin', @@ -63,6 +107,7 @@ module.exports = createInstallTargetAdapter({ repoRoot: input.repoRoot, projectRoot: input.projectRoot, homeDir: input.homeDir, + env: input.env, }; const operations = modules.flatMap(module => { diff --git a/scripts/lib/install-targets/helpers.js b/scripts/lib/install-targets/helpers.js index 3ff268a46..61d17a767 100644 --- a/scripts/lib/install-targets/helpers.js +++ b/scripts/lib/install-targets/helpers.js @@ -7,6 +7,55 @@ const { } = require('../install/claude-settings'); const { METADATA_FILENAME } = require('../hooks-config'); +// Memoized WSL detection (avoids repeated /proc/version reads) +let _isWSL = null; +function isWSL() { + if (_isWSL !== null) return _isWSL; + if (process.platform !== 'linux') { + _isWSL = false; + return false; + } + try { + _isWSL = fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft'); + } catch { + _isWSL = false; + } + return _isWSL; +} + +// Reset WSL detection memoization (for testing only) +function resetWSLDetection() { + _isWSL = null; +} + +// Set WSL detection for testing (for testing only) +function setWSLForTesting(value) { + _isWSL = value; +} + +// Windows path pattern: drive letter followed by :\ or :/ +const WINDOWS_PATH_PATTERN = /^[A-Za-z]:[\\/]/; + +// Compact WSL conversion snippet for inline resolvers embedded in commands and hooks. +// Uses 8 backslashes in regex source so it preserves shell escaping when inlined into `node -e "..."`. +const INLINE_WSL_CONVERSION = `try{if(o.platform()==='linux'){var v=f.readFileSync('/proc/version','utf8').toLowerCase();if(v.includes('microsoft')){var m=d.match(/^([A-Za-z]):[\\\\\\\\/]/);if(m){var dl=m[1].toLowerCase();d='/mnt/'+dl+d.slice(2).replace(/\\\\\\\\/g,'/')}}}}catch(_){}`; + +/** + * Convert a Windows path to a WSL path. + * Example: "C:\Users\name" -> "/mnt/c/Users/name" + * Example: "D:/Projects" -> "/mnt/d/Projects" + * Returns the original path if it's not a Windows path or not on WSL. + */ +function convertWindowsPathToWSL(windowsPath) { + if (!isWSL()) return windowsPath; + if (typeof windowsPath !== 'string') return windowsPath; + const trimmed = windowsPath.trim(); + if (!WINDOWS_PATH_PATTERN.test(trimmed)) return trimmed; + const driveLetter = trimmed[0].toLowerCase(); + const rest = trimmed.slice(2).replace(/\\/g, '/'); + return `/mnt/${driveLetter}${rest}`; +} + const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({ '.claude-plugin': 'claude', '.codex': 'codex', @@ -441,4 +490,9 @@ module.exports = { isForeignPlatformPath, normalizeRelativePath, planClaudeHooksOperations, + isWSL, + resetWSLDetection, + setWSLForTesting, + convertWindowsPathToWSL, + INLINE_WSL_CONVERSION, }; diff --git a/scripts/lib/install/inventory.js b/scripts/lib/install/inventory.js index dc861501d..d38de69fa 100644 --- a/scripts/lib/install/inventory.js +++ b/scripts/lib/install/inventory.js @@ -4,6 +4,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { isWithinRoot, realpathNearestExisting } = require('../path-safety'); +const { convertWindowsPathToWSL } = require('../install-targets/helpers'); const CURRENT_PLUGIN_ID = 'ecc@ecc'; // Legacy plugin ids, kept so existing installs are still recognized. @@ -12,19 +13,44 @@ const LEGACY_PLUGIN_IDS = new Set([ 'everything-claude-code@ecc', ]); +function expandTilde(inputPath, homeDir) { + if (typeof inputPath !== 'string') return inputPath; + const trimmed = inputPath.trim(); + if (trimmed === '~') return homeDir; + if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return path.join(homeDir, trimmed.slice(2)); + } + return trimmed; +} + function resolveClaudePaths(options = {}) { - const homeDir = options.homeDir - || process.env.HOME - || process.env.USERPROFILE - || os.homedir(); - const configDir = options.configDir - || process.env.CLAUDE_CONFIG_DIR - || path.join(homeDir, '.claude'); + const homeDir = path.resolve( + options.homeDir + || process.env.HOME + || process.env.USERPROFILE + || os.homedir() + ); + const configured = options.configDir !== undefined + ? options.configDir + : process.env.CLAUDE_CONFIG_DIR; + let resolvedConfigDir; + if (typeof configured === 'string' && configured.trim() !== '') { + const normalized = configured.trim(); + const converted = convertWindowsPathToWSL(normalized); + const expanded = expandTilde(converted, homeDir); + if (typeof expanded === 'string' && expanded.trim() !== '') { + resolvedConfigDir = path.resolve(expanded.trim()); + } else { + resolvedConfigDir = path.join(homeDir, '.claude'); + } + } else { + resolvedConfigDir = path.join(homeDir, '.claude'); + } const projectRoot = options.projectRoot || process.cwd(); return { - homeDir: path.resolve(homeDir), - configDir: path.resolve(configDir), + homeDir, + configDir: resolvedConfigDir, projectRoot: path.resolve(projectRoot), }; } diff --git a/scripts/lib/install/runtime.js b/scripts/lib/install/runtime.js index eabb930b2..4d9e256c9 100644 --- a/scripts/lib/install/runtime.js +++ b/scripts/lib/install/runtime.js @@ -53,6 +53,7 @@ function createRawInstallPlan(request, options = {}) { languages: request.languages, projectRoot: options.projectRoot, homeDir: options.homeDir, + env: resolveInvocationEnvironment(options), claudeRulesDir: options.claudeRulesDir, sourceRoot: options.sourceRoot, }); diff --git a/scripts/lib/resolve-ecc-root.js b/scripts/lib/resolve-ecc-root.js index b6f6eaf25..157744f3a 100644 --- a/scripts/lib/resolve-ecc-root.js +++ b/scripts/lib/resolve-ecc-root.js @@ -4,6 +4,23 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); +// Lazily loaded so this module can be required from stripped-down directories +// (e.g., temp dirs in tests that copy only resolve-ecc-root.js + utils.js). +// Falls back to identity on failure, which matches non-WSL behavior. +let _convertWindowsPathToWSL = null; +function convertWindowsPathToWSL(windowsPath) { + if (_convertWindowsPathToWSL === null) { + try { + _convertWindowsPathToWSL = require('./install-targets/helpers').convertWindowsPathToWSL; + } catch (_) { + // Module unavailable from this location; non-WSL behavior returns + // the input unchanged, so the fallback is safe. + _convertWindowsPathToWSL = (p) => p; + } + } + return _convertWindowsPathToWSL(windowsPath); +} + const CURRENT_PLUGIN_SLUG = 'ecc'; // Legacy install id, kept for existing installs. const LEGACY_PLUGIN_SLUG = 'everything-claude-code'; @@ -66,7 +83,10 @@ function resolveEccRoot(options = {}) { const configuredDir = options.configDir !== undefined ? options.configDir : process.env.CLAUDE_CONFIG_DIR; - const rawDir = typeof configuredDir === 'string' ? configuredDir.trim() : ''; + // On WSL, convert Windows paths (e.g., C:\Users\name\.claude) to WSL paths + // (/mnt/c/Users/name/.claude) so the resolver finds the correct location. + const wslConfiguredDir = convertWindowsPathToWSL(configuredDir); + const rawDir = typeof wslConfiguredDir === 'string' ? wslConfiguredDir.trim() : ''; const expandedDir = rawDir === '~' ? homeDir : (rawDir.startsWith('~/') || rawDir.startsWith('~\\')) @@ -154,10 +174,41 @@ function normalizePluginRootForPlatform(rootDir, platform = process.platform) { * const _r = ; * const sm = require(_r + '/scripts/lib/session-manager'); */ -const INLINE_RESOLVE = `(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=(process.env.CLAUDE_CONFIG_DIR||'').trim()||p.join(o.homedir(),'.claude');if(d==='~')d=o.homedir();else if(d.charAt(0)==='~'&&(d.charAt(1)==='/'||d.charCodeAt(1)===92))d=p.join(o.homedir(),d.slice(2));d=p.resolve(d);function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i `'${s}'`).join(',')})}`; + } + return `(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=(process.env.CLAUDE_CONFIG_DIR||'').trim()||p.join(o.homedir(),'.claude');if(d==='~')d=o.homedir();else if(d.charAt(0)==='~'&&(d.charAt(1)==='/'||d.charCodeAt(1)===92))d=p.join(o.homedir(),d.slice(2));${wslConversion}d=p.resolve(d);function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot(${probeArg})}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i 128) { return { success: false, error: 'Alias name cannot exceed 128 characters' }; } diff --git a/scripts/lib/skill-evolution/dashboard.js b/scripts/lib/skill-evolution/dashboard.js index edd98ce74..820968905 100644 --- a/scripts/lib/skill-evolution/dashboard.js +++ b/scripts/lib/skill-evolution/dashboard.js @@ -69,7 +69,7 @@ function bucketByDay(records, nowMs, days) { } for (const bucket of buckets) { - if (recordMs > bucket.start && recordMs <= bucket.end) { + if (recordMs >= bucket.start && recordMs <= bucket.end) { bucket.records.push(record); break; } diff --git a/scripts/lib/skill-evolution/tracker.js b/scripts/lib/skill-evolution/tracker.js index 2964028d1..f243cf802 100644 --- a/scripts/lib/skill-evolution/tracker.js +++ b/scripts/lib/skill-evolution/tracker.js @@ -35,8 +35,8 @@ function toNullableNumber(value, fieldName) { } const numericValue = Number(value); - if (!Number.isFinite(numericValue)) { - throw new Error(`${fieldName} must be a number`); + if (!Number.isFinite(numericValue) || numericValue < 0) { + throw new Error(`${fieldName} must be a number greater than or equal to zero`); } return numericValue; diff --git a/scripts/session-inspect.js b/scripts/session-inspect.js index e166c8efe..e6e129065 100644 --- a/scripts/session-inspect.js +++ b/scripts/session-inspect.js @@ -39,7 +39,13 @@ function usage() { function parseArgs(argv) { const args = argv.slice(2); - const target = args.find(argument => !argument.startsWith('--')); + const valueOptions = ['--adapter', '--target-type', '--skill', '--amendment-id', '--observations', '--write']; + for (const [index, argument] of args.entries()) { + if (valueOptions.includes(argument) && (!args[index + 1] || args[index + 1].startsWith('--'))) { + throw new Error(`Missing value for ${argument}`); + } + } + const target = args.find((argument, index) => !argument.startsWith('--') && !valueOptions.includes(args[index - 1])); const listAdapters = args.includes('--list-adapters'); const adapterIndex = args.indexOf('--adapter'); diff --git a/scripts/sessions-cli.js b/scripts/sessions-cli.js index 173e68d4c..339901a27 100644 --- a/scripts/sessions-cli.js +++ b/scripts/sessions-cli.js @@ -28,11 +28,17 @@ function parseArgs(argv) { const arg = args[index]; if (arg === '--db') { + if (!args[index + 1] || args[index + 1].startsWith('--') || args[index + 1] === '-h') { + throw new Error(`Missing value for ${arg}`); + } parsed.dbPath = args[index + 1] || null; index += 1; } else if (arg === '--json') { parsed.json = true; } else if (arg === '--limit') { + if (!args[index + 1] || args[index + 1].startsWith('--') || args[index + 1] === '-h') { + throw new Error(`Missing value for ${arg}`); + } parsed.limit = args[index + 1] || null; index += 1; } else if (arg === '--help' || arg === '-h') { diff --git a/skills/ck/SKILL.md b/skills/ck/SKILL.md index 8f3cbb0b1..12e191050 100644 --- a/skills/ck/SKILL.md +++ b/skills/ck/SKILL.md @@ -12,7 +12,13 @@ repo: https://github.com/sreedhargs89/context-keeper You are the **Context Keeper** assistant. When the user invokes any `/ck:*` command, run the corresponding Node.js script and present its stdout to the user verbatim. -Scripts live at: `~/.claude/skills/ck/commands/` (expand `~` with `$HOME`). +Scripts live in the `commands/` directory next to this SKILL.md (for example +`~/.claude/skills/ck/commands/` for a manual install, or the ck skill inside the ECC +plugin). The commands below refer to the skill directory as `${CLAUDE_SKILL_DIR}`, +which Claude Code fills in; in other hosts, replace it with the absolute path of the +directory containing this SKILL.md. Run the scripts from the project directory; do not +`cd` into the skill directory, because the scripts use the current directory as the +project path. --- @@ -32,7 +38,7 @@ Scripts live at: `~/.claude/skills/ck/commands/` (expand `~` with `$HOME`). ### `/ck:init` — Register a Project ```bash -node "$HOME/.claude/skills/ck/commands/init.mjs" +node "${CLAUDE_SKILL_DIR}/commands/init.mjs" ``` The script outputs JSON with auto-detected info. Present it as a confirmation draft: ``` @@ -44,9 +50,13 @@ Goal: Do-nots: Repo: ``` -Wait for user approval. Apply any edits. Then pipe confirmed JSON to save.mjs --init: +Wait for user approval. Apply any edits. Then pass the confirmed JSON to save.mjs --init +through a quoted heredoc (never `echo '...'`: an apostrophe in any field would end the +quoted string and hand the rest to the shell): ```bash -echo '' | node "$HOME/.claude/skills/ck/commands/save.mjs" --init +node "${CLAUDE_SKILL_DIR}/commands/save.mjs" --init <<'CK_JSON' + +CK_JSON ``` Confirmed JSON schema: `{"name":"...","path":"...","description":"...","stack":["..."],"goal":"...","constraints":["..."],"repo":"..." }` @@ -62,9 +72,11 @@ Confirmed JSON schema: `{"name":"...","path":"...","description":"...","stack":[ - `goal`: updated goal string **only if it changed this session**, else omit Show a draft summary to the user: `"Session: '' — save this? (yes / edit)"` -Wait for confirmation. Then pipe to save.mjs: +Wait for confirmation. Then pass the JSON to save.mjs through a quoted heredoc: ```bash -echo '' | node "$HOME/.claude/skills/ck/commands/save.mjs" +node "${CLAUDE_SKILL_DIR}/commands/save.mjs" <<'CK_JSON' + +CK_JSON ``` JSON schema (exact): `{"summary":"...","leftOff":"...","nextSteps":["..."],"decisions":[{"what":"...","why":"..."}],"blockers":["..."]}` Display the script's stdout confirmation verbatim. @@ -73,7 +85,7 @@ Display the script's stdout confirmation verbatim. ### `/ck:resume [name|number]` — Full Briefing ```bash -node "$HOME/.claude/skills/ck/commands/resume.mjs" [arg] +node "${CLAUDE_SKILL_DIR}/commands/resume.mjs" [arg] ``` Display output verbatim. Then ask: "Continue from here? Or has anything changed?" If user reports changes → run `/ck:save` immediately. @@ -82,7 +94,7 @@ If user reports changes → run `/ck:save` immediately. ### `/ck:info [name|number]` — Quick Snapshot ```bash -node "$HOME/.claude/skills/ck/commands/info.mjs" [arg] +node "${CLAUDE_SKILL_DIR}/commands/info.mjs" [arg] ``` Display output verbatim. No follow-up question. @@ -90,7 +102,7 @@ Display output verbatim. No follow-up question. ### `/ck:list` — Portfolio View ```bash -node "$HOME/.claude/skills/ck/commands/list.mjs" +node "${CLAUDE_SKILL_DIR}/commands/list.mjs" ``` Display output verbatim. If user replies with a number or name → run `/ck:resume`. @@ -101,7 +113,7 @@ First resolve the project name (run `/ck:list` if needed). Ask: `"This will permanently delete context for ''. Are you sure? (yes/no)"` If yes: ```bash -node "$HOME/.claude/skills/ck/commands/forget.mjs" [name] +node "${CLAUDE_SKILL_DIR}/commands/forget.mjs" [name] ``` Display confirmation verbatim. @@ -109,11 +121,11 @@ Display confirmation verbatim. ### `/ck:migrate` — Convert v1 Data to v2 ```bash -node "$HOME/.claude/skills/ck/commands/migrate.mjs" +node "${CLAUDE_SKILL_DIR}/commands/migrate.mjs" ``` For a dry run first: ```bash -node "$HOME/.claude/skills/ck/commands/migrate.mjs" --dry-run +node "${CLAUDE_SKILL_DIR}/commands/migrate.mjs" --dry-run ``` Display output verbatim. Migrates all v1 CONTEXT.md + meta.json files to v2 context.json. Originals are backed up as `meta.json.v1-backup` — nothing is deleted. @@ -122,19 +134,24 @@ Originals are backed up as `meta.json.v1-backup` — nothing is deleted. ## SessionStart Hook -The hook at `~/.claude/skills/ck/hooks/session-start.mjs` must be registered in -`~/.claude/settings.json` to auto-load project context on session start: +The hook at `hooks/session-start.mjs` in this skill's directory must be registered in +`~/.claude/settings.json` to auto-load project context on session start. For a manual +install in `~/.claude/skills/ck`: ```json { "hooks": { "SessionStart": [ - { "hooks": [{ "type": "command", "command": "node \"~/.claude/skills/ck/hooks/session-start.mjs\"" }] } + { "hooks": [{ "type": "command", "command": "node \"$HOME/.claude/skills/ck/hooks/session-start.mjs\"" }] } ] } } ``` +Use `$HOME`, not `~`: the shell does not expand `~` inside double quotes. If ck lives +elsewhere (for example inside the ECC plugin), use the absolute path of its +`hooks/session-start.mjs` instead. + The hook injects ~100 tokens per session (compact 5-line summary). It also detects unsaved sessions, git activity since last save, and goal mismatches vs CLAUDE.md. diff --git a/skills/ck/commands/shared.mjs b/skills/ck/commands/shared.mjs index f826f4c1f..eaac041dd 100644 --- a/skills/ck/commands/shared.mjs +++ b/skills/ck/commands/shared.mjs @@ -6,8 +6,9 @@ */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; -import { resolve } from 'path'; +import { resolve, dirname } from 'path'; import { homedir } from 'os'; +import { fileURLToPath } from 'url'; import { spawnSync } from 'child_process'; import { randomBytes } from 'crypto'; @@ -17,7 +18,7 @@ export const CK_HOME = resolve(homedir(), '.claude', 'ck'); export const CONTEXTS_DIR = resolve(CK_HOME, 'contexts'); export const PROJECTS_FILE = resolve(CK_HOME, 'projects.json'); export const CURRENT_SESSION = resolve(CK_HOME, 'current-session.json'); -export const SKILL_FILE = resolve(homedir(), '.claude', 'skills', 'ck', 'SKILL.md'); +export const SKILL_FILE = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'SKILL.md'); // ─── JSON I/O ───────────────────────────────────────────────────────────────── @@ -183,8 +184,9 @@ export function gitSummary(projectPath, sinceDate) { // ─── Native memory path encoding ────────────────────────────────────────────── export function encodeProjectPath(absolutePath) { - // "/Users/sree/dev/app" -> "-Users-sree-dev-app" - return absolutePath.replace(/\//g, '-'); + // Same rule as Claude Code: every non-alphanumeric character becomes '-'. + // "/Users/sree/dev/my app" -> "-Users-sree-dev-my-app" + return absolutePath.replace(/[^A-Za-z0-9]/g, '-'); } export function nativeMemoryDir(absolutePath) { diff --git a/skills/ck/hooks/session-start.mjs b/skills/ck/hooks/session-start.mjs index e109149a0..47d3623b7 100644 --- a/skills/ck/hooks/session-start.mjs +++ b/skills/ck/hooks/session-start.mjs @@ -15,14 +15,17 @@ */ import { readFileSync, writeFileSync, existsSync } from 'fs'; -import { resolve } from 'path'; +import { resolve, dirname } from 'path'; import { homedir } from 'os'; import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; const CK_HOME = resolve(homedir(), '.claude', 'ck'); const PROJECTS_FILE = resolve(CK_HOME, 'projects.json'); const CURRENT_SESSION = resolve(CK_HOME, 'current-session.json'); -const SKILL_FILE = resolve(homedir(), '.claude', 'skills', 'ck', 'SKILL.md'); +// Resolve SKILL.md next to this hook, so plugin installs work as well as ~/.claude/skills/ck. +const SKILL_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const SKILL_FILE = resolve(SKILL_DIR, 'SKILL.md'); // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -85,7 +88,13 @@ function main() { const sessionId = readSessionId(); // Load skill (always inject — now only ~50 lines) - const skill = existsSync(SKILL_FILE) ? readFileSync(SKILL_FILE, 'utf8') : ''; + // The host does not expand ${CLAUDE_SKILL_DIR} in hook output, so fill it in here. + // SKILL.md uses it inside double-quoted bash, so escape the characters bash + // still interprets there; the callback keeps `$&` etc. in the path literal. + const shellSkillDir = SKILL_DIR.replace(/[\\$`"]/g, '\\$&'); + const skill = existsSync(SKILL_FILE) + ? readFileSync(SKILL_FILE, 'utf8').replaceAll('${CLAUDE_SKILL_DIR}', () => shellSkillDir) + : ''; const projects = readJson(PROJECTS_FILE) || {}; const entry = projects[cwd]; diff --git a/skills/continuous-learning-v2/scripts/instinct-cli.py b/skills/continuous-learning-v2/scripts/instinct-cli.py index d6b0820cd..57fe9a5d9 100755 --- a/skills/continuous-learning-v2/scripts/instinct-cli.py +++ b/skills/continuous-learning-v2/scripts/instinct-cli.py @@ -238,11 +238,10 @@ def _fetch_import_url(source: str, *, max_bytes: int = 2 * 1024 * 1024) -> str: def _yaml_quote(value: str) -> str: """Quote a string for safe YAML frontmatter serialization. - Uses double quotes and escapes embedded double-quote characters to - prevent malformed YAML when the value contains quotes. + JSON double-quoted strings are valid YAML and keep control characters + escaped on one line, preserving instinct frontmatter boundaries. """ - escaped = value.replace('\\', '\\\\').replace('"', '\\"') - return f'"{escaped}"' + return json.dumps(value) # ───────────────────────────────────────────── @@ -546,7 +545,10 @@ def parse_instinct_file(content: str) -> list[dict]: value = value.strip() # Unescape quoted YAML strings if value.startswith('"') and value.endswith('"'): - value = value[1:-1].replace('\\"', '"').replace('\\\\', '\\') + try: + value = json.loads(value) + except json.JSONDecodeError: + value = value[1:-1].replace('\\"', '"').replace('\\\\', '\\') elif value.startswith("'") and value.endswith("'"): value = value[1:-1].replace("''", "'") if key == 'confidence': diff --git a/skills/continuous-learning-v2/scripts/test_parse_instinct.py b/skills/continuous-learning-v2/scripts/test_parse_instinct.py index f1c65f0c6..745614c3b 100644 --- a/skills/continuous-learning-v2/scripts/test_parse_instinct.py +++ b/skills/continuous-learning-v2/scripts/test_parse_instinct.py @@ -281,6 +281,40 @@ def test_parse_confidence_preserves_valid_range(confidence): assert parse_instinct_file(content)[0]["confidence"] == confidence +@pytest.mark.unit +@pytest.mark.parametrize("value", [ + "line one\nline two", "tab\tvalue", "carriage\rreturn", + 'when writing "quoted" text', r"C:\Users\name", "caf\u00e9", + r"literal\ntext", "line\n---\nid: other\n---", "control\x01value", +]) +def test_quoted_instinct_strings_roundtrip(value: str) -> None: + quoted = _mod._yaml_quote(value) + assert json.loads(quoted) == value + instincts = parse_instinct_file(f"---\nid: roundtrip\ntrigger: {quoted}\n---\nBody") + assert len(instincts) == 1 + assert instincts[0]["trigger"] == value + + +@pytest.mark.unit +def test_parse_quoted_instinct_decodes_newline_escape() -> None: + content = '---\nid: newline\ntrigger: "line one\\nline two"\n---\nBody' + assert parse_instinct_file(content)[0]["trigger"] == "line one\nline two" + + +@pytest.mark.unit +@pytest.mark.parametrize("value", ["line\u0085value", "line\u0090value"]) +def test_unicode_controls_are_escaped_for_yaml(value: str) -> None: + quoted = _mod._yaml_quote(value) + assert quoted.isascii() + assert json.loads(quoted) == value + + +@pytest.mark.unit +def test_parse_quoted_instinct_preserves_legacy_escape() -> None: + content = '---\nid: legacy\ntrigger: "C:\\projects"\n---\nBody' + assert parse_instinct_file(content)[0]["trigger"] == r"C:\projects" + + def test_parse_trigger_strips_quotes(): content = """\ --- diff --git a/skills/delivery-gate/SKILL.md b/skills/delivery-gate/SKILL.md index 940e26160..bfd4e2542 100644 --- a/skills/delivery-gate/SKILL.md +++ b/skills/delivery-gate/SKILL.md @@ -21,11 +21,14 @@ This is the same pattern as CI pipeline gates — automated, deterministic check | Check | Mechanism | On Hit | |-------|-----------|--------| | Rationalization patterns | Regex on transcript tail | **Warning only** (never blocks) | -| Stale learning libraries | mtime on 5 configurable paths | Warning if some stale; **Block** if >=3 stale OR growth-log stale + complex task | -| Disk space < 50GB | `shutil.disk_usage` | Warning | -| Disk space < 15GB | `shutil.disk_usage` | **Block** (exit 2) | +| Stale learning libraries | mtime on 5 configurable paths | Warning if some stale; **Block** on a complex task if >=3 stale OR growth-log stale | +| Disk space < 50GB | `shutil.disk_usage` | Reminder | +| Disk space < 30GB | `shutil.disk_usage` | Warning | +| Disk space < 15GB | `shutil.disk_usage` | **Block** (exit 2), on every Stop | -Rationalization detection warns about patterns like "skip tests for now" and "pre-existing bug" — surface signals that thinking may have been cut short. It never blocks on its own, because regex heuristics can false-positive. The blocking conditions are: disk critical, `>=3 learning libs stale`, OR `growth-log` specifically stale (all require complex task >=3 edits). +Rationalization detection warns about patterns like "skip tests for now" and "pre-existing bug" — surface signals that thinking may have been cut short. It never blocks on its own, because regex heuristics can false-positive. The blocking conditions are: disk critical (any session), or, on a complex task (>=3 Edit/Write calls), `>=3 learning libs stale` OR `growth-log` specifically stale. If the project memory directory does not exist, the learning-library checks are skipped: the hook only warns that it cannot verify learning capture, and a complex task is not blocked (the disk checks still apply). + +The hook blocks a given Stop at most once. When Claude Code re-runs it with `stop_hook_active: true` (Claude already continued once because of this hook), it exits 0, so a condition the agent cannot fix (such as low disk space) cannot trap the session in an endless loop. ## Why @@ -50,7 +53,7 @@ Add to `~/.claude/settings.json`: "hooks": [{ "type": "command", "command": "python3 ~/.claude/scripts/quality-gate.py", - "timeout": 5000 + "timeout": 5 }] }] } @@ -59,7 +62,7 @@ Add to `~/.claude/settings.json`: ## Learning Libraries -Create these files in your project's memory directory. The hook checks if at least one was updated today: +Create these files in your project's memory directory (`~/.claude/projects//memory/`, where every non-alphanumeric character of the project path becomes `-`). The hook checks which of them were updated today: ``` memory/ @@ -81,7 +84,8 @@ Edit `quality-gate.py`: | `RATIONALIZE` | 4 patterns | Regex patterns for rationalization detection | | `LIBS` | 5 libraries | Files/dirs to check for today's updates | | `COMPLEX_THRESHOLD` | 3 | Edit/Write calls to classify as complex | -| `DISK_WARN_GB` | 50 | Warn below this | +| `DISK_REMIND_GB` | 50 | Remind below this | +| `DISK_WARN_GB` | 30 | Warn below this | | `DISK_CRIT_GB` | 15 | Block below this | ## Examples @@ -93,24 +97,24 @@ edit_count=1 (< 3, not complex) → exit 0 **Complex task, learning captured — allowed:** ``` -edit_count=5 (complex) → checks LIBS → growth-log updated today → exit 0 +edit_count=5 (complex) → checks LIBS → growth-log + 2 others updated today (2 stale) → exit 0 ``` **Complex task, no learning — BLOCKED:** ``` edit_count=4 (complex) → checks LIBS → all 5 stale → exit 2 -stderr: "Blocked: complex task completed but no learning captured today." +stderr: "Blocked: complex task but >=3 learning libs stale." ``` **Low disk space — BLOCKED:** ``` disk_free=12GB < 15GB critical → exit 2 -stderr: "Blocked: disk space at 12GB (threshold: 15GB)." +stderr: "Blocked: disk space at 12GB (<15GB). Free space before continuing." ``` ## Limitations -The hook enforces the **habit** of touching learning libraries, not the **quality** of what was recorded. If `output-index.md` is updated but `growth-log` is skipped, the hook passes (1 of 5 libraries touched). This is by design: mechanical gates check machine-verifiable facts. For content quality verification, pair with `self-audit`. +The hook enforces the **habit** of touching learning libraries, not the **quality** of what was recorded. On a complex task it passes only when `growth-log` was updated today and at most 2 libraries are stale; updating `output-index.md` alone still blocks. Without a project memory directory there is nothing to check, so it only warns. This is by design: mechanical gates check machine-verifiable facts. For content quality verification, pair with `self-audit`. ## Compatibility diff --git a/skills/delivery-gate/hooks/quality-gate.py b/skills/delivery-gate/hooks/quality-gate.py index 1e78b3d05..90d3b96b6 100644 --- a/skills/delivery-gate/hooks/quality-gate.py +++ b/skills/delivery-gate/hooks/quality-gate.py @@ -55,7 +55,9 @@ def get_project_memory_dir() -> Optional[str]: Returns None if no memory directory exists for this project. Does NOT fall back to other projects (privacy boundary).""" cwd = os.environ.get('CLAUDE_PROJECT_DIR', os.getcwd()) - safe = cwd.replace(':', '-').replace('\\', '-').replace('/', '-') + # Claude Code names project dirs by replacing every non-alphanumeric + # character (including spaces) with '-'. + safe = re.sub(r'[^A-Za-z0-9]', '-', cwd) mem = os.path.expanduser(f'~/.claude/projects/{safe}/memory') log.info('Looking for memory dir: cwd=%s -> %s', cwd, mem) if os.path.isdir(mem): @@ -136,6 +138,9 @@ def main() -> None: transcript = raw try: payload = json.loads(raw) + if isinstance(payload, dict) and payload.get('stop_hook_active') is True: + # This Stop was already blocked once; blocking again would loop forever. + sys.exit(0) if isinstance(payload, dict) and 'transcript_path' in payload: tp = os.path.expanduser(payload['transcript_path']) if os.path.exists(tp): diff --git a/skills/django-security/SKILL.md b/skills/django-security/SKILL.md index 9e1fb25a0..9f25cc764 100644 --- a/skills/django-security/SKILL.md +++ b/skills/django-security/SKILL.md @@ -37,12 +37,10 @@ SECURE_HSTS_SECONDS = 31536000 # 1 year SECURE_HSTS_INCLUDE_SUBDOMAINS = True SECURE_HSTS_PRELOAD = True SECURE_CONTENT_TYPE_NOSNIFF = True -SECURE_BROWSER_XSS_FILTER = True X_FRAME_OPTIONS = 'DENY' # HTTPS and Cookies SESSION_COOKIE_HTTPONLY = True -CSRF_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' CSRF_COOKIE_SAMESITE = 'Lax' @@ -312,7 +310,6 @@ def greet_user(username): ```python # settings.py SECURE_CONTENT_TYPE_NOSNIFF = True # Prevent MIME sniffing -SECURE_BROWSER_XSS_FILTER = True # Enable XSS filter X_FRAME_OPTIONS = 'DENY' # Prevent clickjacking # Custom middleware @@ -338,7 +335,7 @@ class SecurityHeaderMiddleware: ```python # settings.py - CSRF is enabled by default CSRF_COOKIE_SECURE = True # Only send over HTTPS -CSRF_COOKIE_HTTPONLY = True # Prevent JavaScript access +CSRF_COOKIE_HTTPONLY = False # False so AJAX can read csrf token from document.cookie; SESSION_COOKIE_HTTPONLY remains True CSRF_COOKIE_SAMESITE = 'Lax' # Prevent CSRF in some cases CSRF_TRUSTED_ORIGINS = ['https://example.com'] # Trusted domains diff --git a/skills/fastapi-patterns/SKILL.md b/skills/fastapi-patterns/SKILL.md index 6cff4479d..b4a0a5b58 100644 --- a/skills/fastapi-patterns/SKILL.md +++ b/skills/fastapi-patterns/SKILL.md @@ -229,7 +229,7 @@ ActiveUserDep = Annotated[User, Depends(get_current_active_user)] ```python # app/routers/users.py from typing import Annotated -from fastapi import APIRouter, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi.security import OAuth2PasswordRequestForm from app.dependencies import ActiveUserDep, DbDep diff --git a/skills/frontend-slides/scripts/export-pdf.sh b/skills/frontend-slides/scripts/export-pdf.sh index 2ab289f89..8ae2cadc1 100755 --- a/skills/frontend-slides/scripts/export-pdf.sh +++ b/skills/frontend-slides/scripts/export-pdf.sh @@ -73,7 +73,7 @@ if [[ ! -f "$INPUT_HTML" ]]; then fi # Resolve to absolute path -INPUT_HTML=$(cd "$(dirname "$INPUT_HTML")" && pwd)/$(basename "$INPUT_HTML") +INPUT_HTML=$(CDPATH= cd -- "$(dirname "$INPUT_HTML")" && pwd)/$(basename "$INPUT_HTML") # Output PDF path: use second argument or derive from input name if [[ $# -ge 2 ]]; then @@ -82,10 +82,10 @@ else OUTPUT_PDF="$(dirname "$INPUT_HTML")/$(basename "$INPUT_HTML" .html).pdf" fi -# Resolve output to absolute path +# Resolve output to absolute path (the export later runs from a temp dir that is deleted) OUTPUT_DIR=$(dirname "$OUTPUT_PDF") mkdir -p "$OUTPUT_DIR" -OUTPUT_PDF="$OUTPUT_DIR/$(basename "$OUTPUT_PDF")" +OUTPUT_PDF="$(CDPATH= cd -- "$OUTPUT_DIR" && pwd)/$(basename "$OUTPUT_PDF")" echo "" echo -e "${BOLD}========================================${NC}" diff --git a/skills/laravel-security/SKILL.md b/skills/laravel-security/SKILL.md index f039fdfdd..dd65c7e1f 100644 --- a/skills/laravel-security/SKILL.md +++ b/skills/laravel-security/SKILL.md @@ -588,7 +588,7 @@ final class StorePostRequest extends FormRequest public function rules(): array { return [ - 'title' => ['required', 'string', 'max:255', 'sanitize_html'], + 'title' => ['required', 'string', 'max:255'], 'content' => ['required', 'string', 'max:10000'], 'image' => [ 'required', diff --git a/skills/opensource-pipeline/SKILL.md b/skills/opensource-pipeline/SKILL.md index d72f15eb0..3be84b1f4 100644 --- a/skills/opensource-pipeline/SKILL.md +++ b/skills/opensource-pipeline/SKILL.md @@ -82,6 +82,24 @@ Follow the full forking protocol: Wait for completion. Read `{STAGING_PATH}/FORK_REPORT.md`. +The forker's single commit is only the starting point: `gh repo create --push` (Step 7) +publishes commits, not the working tree. Every later change (sanitizer fixes, packager output) +must be folded into that commit, so the public history is one commit that matches the tree the +sanitizer passed. The reports stay out of it. Fold changes in with: + +```bash +cd "{STAGING_PATH}" +grep -qxF SANITIZATION_REPORT.md .git/info/exclude || printf '%s\n' FORK_REPORT.md SANITIZATION_REPORT.md >> .git/info/exclude +git add -A +git rm -q --cached --ignore-unmatch FORK_REPORT.md SANITIZATION_REPORT.md +git commit --amend --no-edit +git status --porcelain -uno # must print nothing +git rev-list --count HEAD # must print 1 +``` + +An amend leaves a secret that was removed from a file out of the new commit. A new commit on top +would publish it in history. + #### Step 4: Run Sanitizer Agent Spawn the `opensource-sanitizer` agent: @@ -113,7 +131,7 @@ Generate SANITIZATION_REPORT.md inside {STAGING_PATH}/ with PASS/FAIL verdict. Wait for completion. Read `{STAGING_PATH}/SANITIZATION_REPORT.md`. **If FAIL:** Show findings to user. Ask: "Fix these and re-scan, or abort?" -- If fix: Apply fixes, re-run sanitizer (maximum 3 retry attempts — after 3 FAILs, present all findings and ask user to fix manually) +- If fix: Apply fixes, fold them into the commit (see Step 3), re-run sanitizer (maximum 3 retry attempts — after 3 FAILs, present all findings and ask user to fix manually) - If abort: Clean up staging directory **Unreadable files.** After the fix/rescan loop settles, read the report's `## Unreadable Files` @@ -168,6 +186,19 @@ Generate: ) ``` +#### Step 5b: Commit the Packaging and Re-Verify + +The packager's files are not committed yet. Fold them into the commit (see Step 3), then run the +sanitizer again exactly as in Step 4, against what will be published. Apply the same gate as +Step 4, including the fix loop and the unreadable-files check. A FAIL here stops the pipeline: +packaging can add a personal email, a home path or a key in an example. + +When the gate passes, record the commit the sanitizer scanned as `{SCANNED_COMMIT}`: + +```bash +git -C "{STAGING_PATH}" rev-parse HEAD +``` + #### Step 6: Final Review Present to user: @@ -184,7 +215,7 @@ Files generated: - CONTRIBUTING.md - .env.example ({N} variables) -Sanitization: {sanitization_verdict} +Sanitization: {sanitization_verdict} (scanned after packaging, on the commit that will be pushed) Next steps: 1. Review: cd {STAGING_PATH} @@ -196,8 +227,20 @@ Proceed with GitHub creation? (yes/no/review first) #### Step 7: GitHub Publish (on user approval) +Push only the commit the sanitizer passed. If the tree or the commit changed since Step 5b (for +example the user edited files after "review first" and amended), go back to Step 5b instead of +pushing. The two generated reports may remain untracked even when a source `.gitignore` negates +their names. Allow only those two untracked files; a report in the index or scanned commit, or any +other worktree change, still stops publication. + ```bash cd "{STAGING_PATH}" +test -z "$(git ls-files -- FORK_REPORT.md SANITIZATION_REPORT.md)" \ + && test -z "$(git ls-tree -r --name-only HEAD -- FORK_REPORT.md SANITIZATION_REPORT.md)" \ + && test -z "$(git status --porcelain --untracked-files=all -- . ':(exclude)FORK_REPORT.md' ':(exclude)SANITIZATION_REPORT.md')" \ + && test "$(git rev-list --count HEAD)" = 1 \ + && test "$(git rev-parse HEAD)" = "{SCANNED_COMMIT}" \ + || { echo "Uncommitted changes, extra commits, or HEAD is not the scanned commit: return to Step 5b" >&2; exit 1; } gh repo create "{github_org}/{github_repo}" --public --source=. --push --description "{description}" ``` @@ -270,13 +313,14 @@ $HOME/opensource-staging/ - **Never** push to GitHub without user approval - **Never** skip the sanitizer — it is the safety gate - **Never** proceed after a sanitizer FAIL without fixing all critical findings +- **Never** push with uncommitted changes or more than one commit: the push sends commits, so unfolded fixes are not published and the original secret is - **Never** leave `.env`, `*.pem`, or `credentials.json` in the staging directory ## Best Practices - Always run the full pipeline (fork → sanitize → package) for new releases - The staging directory persists until explicitly cleaned up — use it for review -- Re-run the sanitizer after any manual fixes before publishing +- After any manual fix, fold it into the single commit and re-run the sanitizer before publishing - Parameterize secrets rather than deleting them — preserve project functionality ## Related Skills diff --git a/skills/perl-security/SKILL.md b/skills/perl-security/SKILL.md index 7bb7e470f..df4dddce5 100644 --- a/skills/perl-security/SKILL.md +++ b/skills/perl-security/SKILL.md @@ -412,10 +412,6 @@ Always encode output for its context: `HTML::Entities::encode_entities()` for HT severity = 3 theme = security + core -# Require three-arg open -[InputOutput::RequireThreeArgOpen] -severity = 5 - # Require checked system calls [InputOutput::RequireCheckedSyscalls] functions = :builtins @@ -429,10 +425,6 @@ severity = 5 [InputOutput::ProhibitBacktickOperators] severity = 4 -# Require taint checking in CGI -[Modules::RequireTaintChecking] -severity = 5 - # Prohibit two-arg open [InputOutput::ProhibitTwoArgOpen] severity = 5 diff --git a/skills/python-testing/SKILL.md b/skills/python-testing/SKILL.md index ddfcc0abc..8e68a084a 100644 --- a/skills/python-testing/SKILL.md +++ b/skills/python-testing/SKILL.md @@ -224,7 +224,7 @@ def test_without_fixture_call(): assert Config.get_setting("debug") is False ``` -### Conftest.py for Shared Fixtures +### Conftest.py for Shared Fixtures (Flask) ```python # tests/conftest.py @@ -484,15 +484,23 @@ async def test_async_with_fixture(async_client): assert response.status_code == 200 ``` -### Async Fixture +### Async Fixture (FastAPI) + +`ASGITransport` does not run lifespan events. Install `asgi-lifespan` and wrap the client in `LifespanManager` when the app uses startup or shutdown handlers. See [FastAPI's async testing guidance](https://github.com/fastapi/fastapi/blob/master/docs/en/docs/advanced/async-tests.md). ```python -@pytest.fixture +import pytest +import pytest_asyncio +from asgi_lifespan import LifespanManager +from httpx import ASGITransport, AsyncClient + +@pytest_asyncio.fixture async def async_client(): - """Async fixture providing async test client.""" - app = create_app() - async with app.test_client() as client: - yield client + """Async fixture providing async test client with lifespan events.""" + app = create_app() # Returns a FastAPI application + async with LifespanManager(app) as manager: + async with AsyncClient(transport=ASGITransport(app=manager.app), base_url="http://test") as client: + yield client @pytest.mark.asyncio async def test_api_endpoint(async_client): @@ -661,7 +669,7 @@ class TestUserService: ## Common Patterns -### Testing API Endpoints (FastAPI/Flask) +### Testing API Endpoints (Flask) ```python @pytest.fixture diff --git a/skills/remotion-video-creation/rules/fonts.md b/skills/remotion-video-creation/rules/fonts.md index c10cd83e6..0c2f1fa7e 100644 --- a/skills/remotion-video-creation/rules/fonts.md +++ b/skills/remotion-video-creation/rules/fonts.md @@ -33,7 +33,7 @@ export const MyComposition = () => { }; ``` -Preferrably, specify only needed weights and subsets to reduce file size: +Preferably, specify only needed weights and subsets to reduce file size: ```tsx import { loadFont } from "@remotion/google-fonts/Roboto"; diff --git a/skills/safety-guard/SKILL.md b/skills/safety-guard/SKILL.md index 3a4aa11ee..935bb69f6 100644 --- a/skills/safety-guard/SKILL.md +++ b/skills/safety-guard/SKILL.md @@ -1,6 +1,6 @@ --- name: safety-guard -description: "Guard against destructive operations with three modes: Careful intercepts dangerous commands (rm -rf, git push --force, DROP TABLE) for confirmation, Freeze locks writes to one directory, and Guard combines both via PreToolUse hooks. Use when working on production systems, running agents autonomously, restricting edits to a directory, or during migrations, deploys, and data changes." +description: "Guard against destructive operations with three modes: Careful intercepts dangerous commands (rm -rf, git push --force, DROP TABLE) for confirmation, Freeze locks writes to one directory, and Guard combines both. Advisory: the agent applies these rules itself; no hook ships with it. Use when working on production systems, running agents autonomously, restricting edits to a directory, or during migrations, deploys, and data changes." metadata: origin: ECC --- @@ -16,11 +16,11 @@ metadata: ## How It Works -Three modes of protection: +Three modes of protection. They are instructions the agent follows, not enforced blocks (see [Enforcement](#enforcement)). ### Mode 1: Careful Mode -Intercepts destructive commands before execution and warns: +Before it runs a destructive command, the agent stops and warns: ``` Watched patterns: @@ -41,36 +41,25 @@ When detected: shows what the command does, asks for confirmation, suggests safe ### Mode 2: Freeze Mode -Locks file edits to a specific directory tree: +Locks file edits to a specific directory tree, for example: "freeze edits to `src/components/`". -``` -/safety-guard freeze src/components/ -``` - -Any Write/Edit outside `src/components/` is blocked with an explanation. Useful when you want an agent to focus on one area without touching unrelated code. +The agent does not Write/Edit outside `src/components/`, and explains why when a change would need it. Useful when you want an agent to focus on one area without touching unrelated code. ### Mode 3: Guard Mode (Careful + Freeze combined) -Both protections active. Maximum safety for autonomous agents. +Both protections active, for example: "guard mode, write only to `src/api/`, read anything". -``` -/safety-guard guard --dir src/api/ --allow-read-all -``` - -Agents can read anything but only write to `src/api/`. Destructive commands are blocked everywhere. +The agent can read anything but only writes to `src/api/`, and asks before any destructive command. ### Unlock -``` -/safety-guard off -``` +Tell the agent to stop applying the rules. -## Implementation +## Enforcement -Uses PreToolUse hooks to intercept Bash, Write, Edit, and MultiEdit tool calls. Checks the command/path against the active rules before allowing execution. +This skill is advisory. ECC ships no `/safety-guard` command and no hook for it, so nothing blocks a tool call that breaks these rules. For blocking that does not depend on the agent, use Claude Code [permission deny rules](https://code.claude.com/docs/en/permissions) (for example `Bash(rm -rf *)`) or a [PreToolUse hook](https://code.claude.com/docs/en/hooks). ## Integration - Enable by default for `codex -a never` sessions - Pair with observability risk scoring in ECC 2.0 -- Logs all blocked actions to `~/.claude/safety-guard.log` diff --git a/skills/skill-comply/scripts/classifier.py b/skills/skill-comply/scripts/classifier.py index 76d7c87aa..64494a961 100644 --- a/skills/skill-comply/scripts/classifier.py +++ b/skills/skill-comply/scripts/classifier.py @@ -40,7 +40,7 @@ def classify_events( for i, event in enumerate(trace) ) - prompt_template = (PROMPTS_DIR / "classifier.md").read_text() + prompt_template = (PROMPTS_DIR / "classifier.md").read_text(encoding="utf-8") prompt = ( prompt_template .replace("{steps_description}", steps_desc) @@ -48,9 +48,10 @@ def classify_events( ) result = subprocess.run( - ["claude", "-p", prompt, "--model", model, "--output-format", "text"], + ["claude", "-p", "--model", model, "--output-format", "text"], + input=prompt, capture_output=True, - text=True, + encoding="utf-8", timeout=60, ) @@ -85,6 +86,8 @@ def _parse_classification(text: str) -> dict[str, list[int]]: raise ClassifierParseError( f"classifier returned non-list JSON value: {type(value).__name__}" ) + if any(isinstance(i, bool) or (isinstance(i, float) and not i.is_integer()) for i in value): + raise ValueError("tool call indices must be whole numbers, not booleans") return {k: [int(i) for i in v] for k, v in parsed.items()} except (json.JSONDecodeError, ValueError, TypeError, OverflowError) as e: raise ClassifierParseError( diff --git a/skills/skill-comply/scripts/grader.py b/skills/skill-comply/scripts/grader.py index 1209d042a..6c699f141 100644 --- a/skills/skill-comply/scripts/grader.py +++ b/skills/skill-comply/scripts/grader.py @@ -183,7 +183,7 @@ def grade( detected_required = sum(1 for s in required_steps if s.detected) total_required = len(required_steps) - compliance_rate = detected_required / total_required if total_required > 0 else 0.0 + compliance_rate = detected_required / total_required if total_required > 0 else 1.0 return ComplianceResult( spec_id=spec.id, diff --git a/skills/skill-comply/scripts/parser.py b/skills/skill-comply/scripts/parser.py index 0b8169d4f..30b99a011 100644 --- a/skills/skill-comply/scripts/parser.py +++ b/skills/skill-comply/scripts/parser.py @@ -49,7 +49,7 @@ def parse_trace(path: Path) -> list[ObservationEvent]: if not path.is_file(): raise FileNotFoundError(f"Trace file not found: {path}") - text = path.read_text().strip() + text = path.read_text(encoding="utf-8").strip() if not text: return [] @@ -78,7 +78,7 @@ def parse_spec(path: Path) -> ComplianceSpec: """Parse a YAML compliance spec file.""" if not path.is_file(): raise FileNotFoundError(f"Spec file not found: {path}") - raw = yaml.safe_load(path.read_text()) + raw = yaml.safe_load(path.read_text(encoding="utf-8")) steps: list[Step] = [] for s in raw["steps"]: diff --git a/skills/skill-comply/scripts/run.py b/skills/skill-comply/scripts/run.py index 3e4695e51..ede6186af 100644 --- a/skills/skill-comply/scripts/run.py +++ b/skills/skill-comply/scripts/run.py @@ -106,7 +106,7 @@ def main() -> None: report = generate_report(args.skill, spec, graded_results, scenarios=scenarios) output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(report) + output_path.write_text(report, encoding="utf-8") logger.info(" Report saved to %s", output_path) # Summary diff --git a/skills/skill-comply/scripts/runner.py b/skills/skill-comply/scripts/runner.py index eeb8fea32..52e986b0d 100644 --- a/skills/skill-comply/scripts/runner.py +++ b/skills/skill-comply/scripts/runner.py @@ -50,7 +50,7 @@ def run_scenario( result = subprocess.run( [ - "claude", "-p", scenario.prompt, + "claude", "-p", "--model", model, "--max-turns", str(max_turns), "--add-dir", str(sandbox_dir), @@ -58,8 +58,9 @@ def run_scenario( "--output-format", "stream-json", "--verbose", ], + input=scenario.prompt, capture_output=True, - text=True, + encoding="utf-8", timeout=timeout, cwd=sandbox_dir, ) diff --git a/skills/skill-comply/scripts/scenario_generator.py b/skills/skill-comply/scripts/scenario_generator.py index db8db26a1..9733fe5cf 100644 --- a/skills/skill-comply/scripts/scenario_generator.py +++ b/skills/skill-comply/scripts/scenario_generator.py @@ -32,8 +32,8 @@ def generate_scenarios( Calls claude -p with the scenario_generator prompt, parses YAML output. """ - skill_content = skill_path.read_text() - prompt_template = (PROMPTS_DIR / "scenario_generator.md").read_text() + skill_content = skill_path.read_text(encoding="utf-8") + prompt_template = (PROMPTS_DIR / "scenario_generator.md").read_text(encoding="utf-8") prompt = ( prompt_template .replace("{skill_content}", skill_content) @@ -41,9 +41,10 @@ def generate_scenarios( ) result = subprocess.run( - ["claude", "-p", prompt, "--model", model, "--output-format", "text"], + ["claude", "-p", "--model", model, "--output-format", "text"], + input=prompt, capture_output=True, - text=True, + encoding="utf-8", timeout=120, ) diff --git a/skills/skill-comply/scripts/spec_generator.py b/skills/skill-comply/scripts/spec_generator.py index 3b3661736..c15382d9a 100644 --- a/skills/skill-comply/scripts/spec_generator.py +++ b/skills/skill-comply/scripts/spec_generator.py @@ -24,8 +24,8 @@ def generate_spec( Calls claude -p with the spec_generator prompt, parses YAML output. Retries on YAML parse errors with error feedback. """ - skill_content = skill_path.read_text() - prompt_template = (PROMPTS_DIR / "spec_generator.md").read_text() + skill_content = skill_path.read_text(encoding="utf-8") + prompt_template = (PROMPTS_DIR / "spec_generator.md").read_text(encoding="utf-8") base_prompt = prompt_template.replace("{skill_content}", skill_content) last_error: Exception | None = None @@ -41,9 +41,10 @@ def generate_spec( ) result = subprocess.run( - ["claude", "-p", prompt, "--model", model, "--output-format", "text"], + ["claude", "-p", "--model", model, "--output-format", "text"], + input=prompt, capture_output=True, - text=True, + encoding="utf-8", timeout=120, ) @@ -54,7 +55,7 @@ def generate_spec( tmp_path = None with tempfile.NamedTemporaryFile( - mode="w", suffix=".yaml", delete=False, + mode="w", suffix=".yaml", delete=False, encoding="utf-8", ) as f: f.write(raw_yaml) tmp_path = Path(f.name) diff --git a/skills/skill-comply/tests/test_classifier.py b/skills/skill-comply/tests/test_classifier.py index a94f44fe5..a9e80a599 100644 --- a/skills/skill-comply/tests/test_classifier.py +++ b/skills/skill-comply/tests/test_classifier.py @@ -28,7 +28,12 @@ class TestParseClassification: _parse_classification('{"a": [0], "other": "x"}') def test_existing_index_coercion_is_preserved(self) -> None: - assert _parse_classification('{"a": [true, 2.9, "3"]}') == {"a": [1, 2, 3]} + assert _parse_classification('{"a": [0, 2.0, "3"]}') == {"a": [0, 2, 3]} + + @pytest.mark.parametrize("index", [True, False, 2.9, -0.1]) + def test_boolean_and_fractional_indices_raise(self, index: object) -> None: + with pytest.raises(classifier.ClassifierParseError, match="failed to parse"): + _parse_classification(json.dumps({"a": [index]})) def test_empty_object_is_valid(self) -> None: assert _parse_classification("{}") == {} @@ -54,7 +59,7 @@ class TestParseClassification: assert issubclass(classifier.ClassifierParseError, RuntimeError) -@pytest.mark.parametrize("response", ["not JSON", "[]", '{"a": ["x"]}', '{"a": "x"}', '{"a": [0], "other": null}', '{"a": [1e309]}', '{"a": [-1e309]}']) +@pytest.mark.parametrize("response", ["not JSON", "[]", '{"a": ["x"]}', '{"a": "x"}', '{"a": [0], "other": null}', '{"a": [1e309]}', '{"a": [-1e309]}', '{"a": [false]}', '{"a": [0.5]}']) def test_grader_propagates_classifier_parse_failure(response: str) -> None: """An invalid successful subprocess response cannot become a zero score.""" spec = ComplianceSpec( diff --git a/skills/skill-comply/tests/test_grader.py b/skills/skill-comply/tests/test_grader.py index 8ca056452..a029390c5 100644 --- a/skills/skill-comply/tests/test_grader.py +++ b/skills/skill-comply/tests/test_grader.py @@ -1,5 +1,6 @@ """Tests for grader module — compliance scoring with LLM classification.""" +from dataclasses import replace from pathlib import Path from unittest.mock import MagicMock, patch @@ -120,6 +121,48 @@ class TestGradeNoncompliant: class TestGradeEdgeCases: + @pytest.mark.parametrize("detected", [False, True]) + @patch("scripts.grader.classify_events") + def test_optional_only_spec_is_compliant( + self, mock_cls: MagicMock, detected: bool, + tdd_spec: ComplianceSpec, compliant_trace: list[ObservationEvent], + ) -> None: + optional = replace( + next(step for step in tdd_spec.steps if not step.required), + detector=Detector("Refactor if applicable"), + ) + spec = replace(tdd_spec, steps=(optional,)) + mock_cls.return_value = {optional.id: [4]} if detected else {} + + result = grade(spec, compliant_trace) + + assert result.steps[0].detected is detected + assert result.compliance_rate == 1.0 + assert result.recommend_hook_promotion is False + + def test_spec_without_steps_is_compliant(self, tdd_spec: ComplianceSpec) -> None: + result = grade(replace(tdd_spec, steps=()), []) + + assert result.steps == () + assert result.compliance_rate == 1.0 + assert result.recommend_hook_promotion is False + + @patch("scripts.grader.classify_events", return_value={"refactor": [4]}) + def test_optional_detection_does_not_satisfy_required_steps( + self, mock_cls: MagicMock, + tdd_spec: ComplianceSpec, compliant_trace: list[ObservationEvent], + ) -> None: + optional = replace( + next(step for step in tdd_spec.steps if not step.required), + detector=Detector("Refactor if applicable"), + ) + spec = replace(tdd_spec, steps=(tdd_spec.steps[0], optional)) + result = grade(spec, compliant_trace) + + assert next(step for step in result.steps if step.step_id == "refactor").detected + assert result.compliance_rate == 0.0 + assert result.recommend_hook_promotion is True + @patch("scripts.grader.classify_events", side_effect=_mock_empty_classification) def test_empty_trace(self, mock_cls, tdd_spec) -> None: result = grade(tdd_spec, []) diff --git a/skills/skill-comply/tests/test_utf8.py b/skills/skill-comply/tests/test_utf8.py new file mode 100644 index 000000000..be97de9a9 --- /dev/null +++ b/skills/skill-comply/tests/test_utf8.py @@ -0,0 +1,211 @@ +"""UTF-8 file and CLI boundaries must preserve Unicode under legacy locales.""" + +import json +import locale +import os +import subprocess +import sys +from pathlib import Path +from textwrap import dedent +from typing import Any, Callable + +import pytest +import yaml + +from scripts import classifier, parser, run, runner, scenario_generator, spec_generator +from scripts.grader import ComplianceResult +from scripts.parser import ComplianceSpec, Detector, ObservationEvent, Step +from scripts.runner import ScenarioRun +from scripts.scenario_generator import Scenario + +TEXT = "caf\u00e9 \u65e5\u672c" +PROMPT_TEXT = "consign\u00e9e" +SPEC = {"id": "unicode", "name": TEXT, "source_rule": TEXT, "version": "1", + "steps": [], "scoring": {"threshold_promote_to_hook": 0.6}} + + +def mock_cli(payload: str) -> Callable[..., subprocess.CompletedProcess[str]]: + """Emulate decoding UTF-8 CLI bytes using the caller's selected encoding.""" + def execute(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + assert command[:2] == ["claude", "-p"] + assert TEXT in kwargs["input"] + encoding = kwargs.get("encoding") or locale.getpreferredencoding(False) + return subprocess.CompletedProcess(command, 0, stdout=payload.encode("utf-8").decode(encoding), stderr="") + return execute + + +@pytest.mark.parametrize("kind", ["trace", "spec"]) +def test_parser_reads_utf8(tmp_path: Path, kind: str) -> None: + source = tmp_path / "source.txt" + if kind == "trace": + record = {"timestamp": "T0000", "event": "tool_complete", "tool": "Read", + "session": "unicode", "input": TEXT, "output": TEXT} + source.write_text(json.dumps(record, ensure_ascii=False), encoding="utf-8") + assert parser.parse_trace(source)[0].input == TEXT + else: + source.write_text(yaml.safe_dump(SPEC, allow_unicode=True), encoding="utf-8") + assert parser.parse_spec(source).name == TEXT + + +def test_invalid_unicode_trace_keeps_line_diagnostic(tmp_path: Path) -> None: + source = tmp_path / "invalid.jsonl" + source.write_text('{"input": "' + TEXT + '", invalid}', encoding="utf-8") + with pytest.raises(ValueError, match="Invalid JSON at line 1"): + parser.parse_trace(source) + + +@pytest.mark.parametrize("kind", ["spec", "scenarios"]) +def test_generators_preserve_utf8(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, kind: str) -> None: + skill = tmp_path / "SKILL.md" + skill.write_text(TEXT, encoding="utf-8") + module = spec_generator if kind == "spec" else scenario_generator + monkeypatch.setattr(module, "PROMPTS_DIR", tmp_path) + (tmp_path / f"{kind[:-1] if kind == 'scenarios' else kind}_generator.md").write_text( + PROMPT_TEXT + " {skill_content} {spec_yaml}", encoding="utf-8", + ) + response = SPEC if kind == "spec" else {"scenarios": [ + {"id": "one", "level": 1, "level_name": TEXT, "description": TEXT, "prompt": TEXT}, + ]} + execute = mock_cli(yaml.safe_dump(response, allow_unicode=True)) + def checked_execute(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + assert PROMPT_TEXT in kwargs["input"] + return execute(command, **kwargs) + monkeypatch.setattr(module.subprocess, "run", checked_execute) + if kind == "spec": + assert spec_generator.generate_spec(skill, max_retries=0).name == TEXT + else: + assert scenario_generator.generate_scenarios(skill, "steps: []")[0].prompt == TEXT + + +def test_spec_retry_preserves_utf8(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + skill = tmp_path / "SKILL.md" + skill.write_text(TEXT, encoding="utf-8") + responses = iter(["steps: [invalid", yaml.safe_dump(SPEC, allow_unicode=True)]) + prompts: list[str] = [] + def execute(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + prompts.append(kwargs["input"]) + return mock_cli(next(responses))(command, **kwargs) + monkeypatch.setattr(spec_generator.subprocess, "run", execute) + assert spec_generator.generate_spec(skill, max_retries=1).name == TEXT + assert len(prompts) == 2 + assert "PREVIOUS ATTEMPT FAILED" in prompts[1] + + +def test_classifier_preserves_utf8(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (tmp_path / "classifier.md").write_text(PROMPT_TEXT + " {steps_description} {tool_calls}", encoding="utf-8") + monkeypatch.setattr(classifier, "PROMPTS_DIR", tmp_path) + execute = mock_cli(json.dumps({TEXT: [0]}, ensure_ascii=False)) + def checked_execute(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + assert PROMPT_TEXT in kwargs["input"] + return execute(command, **kwargs) + monkeypatch.setattr(classifier.subprocess, "run", checked_execute) + spec = ComplianceSpec("unicode", TEXT, TEXT, "1", (Step(TEXT, TEXT, True, Detector(TEXT)),), 0.6) + trace = [ObservationEvent("T0000", "tool_complete", "Read", "unicode", TEXT, TEXT)] + assert classifier.classify_events(spec, trace) == {TEXT: [0]} + + +def test_runner_preserves_utf8_capture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(runner, "SANDBOX_BASE", tmp_path) + monkeypatch.setattr(runner, "_setup_sandbox", lambda *_args: None) + messages = [ + {"type": "assistant", "message": {"content": [{"type": "tool_use", "id": "one", "name": "Read", "input": TEXT}]}}, + {"type": "user", "session_id": "unicode", "message": {"content": [{"type": "tool_result", "tool_use_id": "one", "content": TEXT}]}}, + ] + payload = "\n".join(json.dumps(message, ensure_ascii=False) for message in messages) + monkeypatch.setattr(runner.subprocess, "run", mock_cli(payload)) + scenario = Scenario("unicode", 1, "one", TEXT, TEXT, ()) + assert runner.run_scenario(scenario).observations[0].output == TEXT + + +@pytest.mark.skipif(os.name == "nt", reason="Windows does not use the POSIX locale for argv encoding") +def test_real_cli_receives_utf8_prompt_on_stdin(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A real child process catches Unicode argv failures that mocked run calls miss.""" + fake_cli = tmp_path / "claude" + fake_cli.write_text( + "#!/usr/bin/env python3\n" + "import os, sys\n" + "data = sys.stdin.buffer.read()\n" + "with open(os.environ['ECC_FAKE_CLAUDE_STDIN'], 'wb') as stream:\n" + " stream.write(data)\n" + "with open(os.environ['ECC_FAKE_CLAUDE_RESPONSE'], 'rb') as stream:\n" + " sys.stdout.buffer.write(stream.read())\n", + encoding="ascii", + ) + fake_cli.chmod(0o755) + captured = tmp_path / "stdin.bin" + response = tmp_path / "response.yaml" + response.write_text(yaml.safe_dump(SPEC, allow_unicode=True), encoding="utf-8") + monkeypatch.setenv("ECC_FAKE_CLAUDE_STDIN", str(captured)) + monkeypatch.setenv("ECC_FAKE_CLAUDE_RESPONSE", str(response)) + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ["PATH"]) + + skill = tmp_path / "SKILL.md" + skill.write_text(TEXT, encoding="utf-8") + assert spec_generator.generate_spec(skill, max_retries=0).name == TEXT + assert TEXT.encode("utf-8") in captured.read_bytes() + + +def test_report_is_written_as_utf8(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + skill = tmp_path / "SKILL.md" + skill.write_text(TEXT, encoding="utf-8") + output = tmp_path / "report.md" + monkeypatch.setattr(run, "__file__", str(tmp_path / "scripts/run.py")) + monkeypatch.setattr(sys, "argv", ["skill-comply", str(skill), "--output", str(output)]) + monkeypatch.setattr(run, "generate_spec", lambda *_args, **_kwargs: parser.ComplianceSpec("unicode", TEXT, TEXT, "1", (), 0.6)) + scenario = Scenario("unicode", 1, TEXT, TEXT, TEXT, ()) + observation = ObservationEvent("T0000", "tool_complete", "Read", "unicode", TEXT, TEXT) + monkeypatch.setattr(run, "generate_scenarios", lambda *_args, **_kwargs: [scenario]) + monkeypatch.setattr(run, "run_scenario", lambda *_args, **_kwargs: ScenarioRun(scenario, (observation,), tmp_path)) + monkeypatch.setattr(run, "grade", lambda *_args, **_kwargs: ComplianceResult("unicode", (), 0.5, True, {})) + monkeypatch.setattr(run, "generate_report", lambda *_args, **_kwargs: TEXT) + run.main() + assert output.read_text(encoding="utf-8") == TEXT + + +def test_utf8_boundaries_under_legacy_encoding() -> None: + if os.environ.get("ECC_UTF8_TEST_CHILD") == "1": + pytest.skip("The child process must not launch another child") + script = dedent(""" + import codecs + import io + import locale + import os + import sys + from typing import IO, Any + + assert sys.flags.utf8_mode == 0 + if os.name == "nt": + # Windows ignores LC_ALL for text I/O; emulate its legacy default. + original_open = io.open + def legacy_open( + file: Any, mode: str = "r", buffering: int = -1, + encoding: str | None = None, *args: Any, **kwargs: Any, + ) -> IO[Any]: + if "b" not in mode and encoding in (None, "locale"): + encoding = "cp1252" + return original_open(file, mode, buffering, encoding, *args, **kwargs) + io.open = legacy_open + locale.getencoding = lambda: "cp1252" + locale.getpreferredencoding = lambda do_setlocale=True: "cp1252" + else: + assert codecs.lookup(locale.getencoding()).name == "ascii" + with io.open(os.devnull) as probe: + assert codecs.lookup(probe.encoding).name in {"ascii", "cp1252"} + + import pytest + raise SystemExit(pytest.main(sys.argv[1:])) + """) + source = Path(__file__).resolve() + result = subprocess.run( + [sys.executable, "-X", "utf8=0", "-c", script, str(source), + "-c", str(source.parents[1] / "pyproject.toml"), "-q", + "-k", "not test_utf8_boundaries_under_legacy_encoding", "-p", "no:cacheprovider"], + cwd=source.parents[1], + env={**os.environ, "LC_ALL": "C", "PYTHONCOERCELOCALE": "0", "PYTHONUTF8": "0", + "PYTHONIOENCODING": "utf-8", "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_ADDOPTS": "", "ECC_UTF8_TEST_CHILD": "1"}, + capture_output=True, + encoding="utf-8", + timeout=120, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/skills/social-publisher/SKILL.md b/skills/social-publisher/SKILL.md index a00651738..b745b504c 100644 --- a/skills/social-publisher/SKILL.md +++ b/skills/social-publisher/SKILL.md @@ -32,6 +32,8 @@ npm install -g socialclaw@0.1.12 socialclaw login --api-key ``` +The CLI takes the key only as the `--api-key` argument and saves it to `~/.socialclaw/config.json`. While the command runs, other local users can see the argument in `ps`, and your shell may keep it in history. Run `socialclaw login` yourself, once, in your own terminal; do not have the agent expand `$SC_API_KEY` into the command line. + ## Core Workflow ### 1. List connected accounts @@ -83,6 +85,9 @@ socialclaw validate -f schedule.json --json ``` ### 5. Publish + +`apply` creates or schedules real posts on the user's accounts. Before running it, show the user every post in the validated `schedule.json` (provider, account, text, media, `scheduled_at`) and get explicit approval. If `schedule.json` changes after approval, ask again. Never run `apply` in the same step that generated the content. + ```bash socialclaw apply -f schedule.json --json # → { "run_id": "..." } diff --git a/tests/commands/santa-loop.test.js b/tests/commands/santa-loop.test.js new file mode 100644 index 000000000..69248e18f --- /dev/null +++ b/tests/commands/santa-loop.test.js @@ -0,0 +1,92 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const repoRoot = path.resolve(__dirname, '..', '..'); +const commands = [ + 'commands/santa-loop.md', + 'docs/ja-JP/commands/santa-loop.md', + 'docs/zh-CN/commands/santa-loop.md', +]; + +if (process.platform === 'win32') { + console.log('Skipped: Santa Loop examples require Bash'); + console.log('Passed: 0'); + console.log('Failed: 0'); + process.exit(0); +} + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (error) { + console.log(` ✗ ${name}: ${error.message}`); + failed++; + } +} + +function runCodexExample(commandPath, configuredModel) { + const source = fs.readFileSync(path.join(repoRoot, commandPath), 'utf8'); + const invocation = source.match(/^\s*codex exec[^\n]+/m); + assert.ok(invocation, 'Expected a Codex reviewer invocation'); + const tempRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-santa-model-'))); + try { + const promptFile = path.join(tempRoot, 'prompt.md'); + fs.writeFileSync(promptFile, 'Independent review only.'); + fs.writeFileSync(path.join(tempRoot, 'codex.js'), `const fs = require('fs'); +const args = process.argv.slice(2); +const modelFlag = args.findIndex(arg => arg === '-m' || arg === '--model'); +const model = modelFlag < 0 ? process.env.ECC_TEST_CONFIGURED_MODEL : args[modelFlag + 1]; +if (model !== process.env.ECC_TEST_CONFIGURED_MODEL) { + console.error('400: model is not supported by this account'); + process.exit(1); +} +console.log(JSON.stringify({ args, model, prompt: fs.readFileSync(0, 'utf8') })); +`); + fs.writeFileSync(path.join(tempRoot, 'codex'), '#!/bin/sh\nexec "$ECC_TEST_NODE" "$0.js" "$@"\n', { mode: 0o755 }); + // Ignore exported functions and startup scripts so CLI dispatch stays inside the fixture. + const result = spawnSync('bash', ['-p', '-c', invocation[0]], { + cwd: tempRoot, + env: { ...process.env, PATH: `${tempRoot}${path.delimiter}${process.env.PATH}`, + PROMPT_FILE: promptFile, ECC_TEST_CONFIGURED_MODEL: configuredModel, ECC_TEST_NODE: process.execPath }, + encoding: 'utf8', timeout: 10000, + }); + assert.strictEqual(result.status, 0, result.stderr); + return { ...JSON.parse(result.stdout), cwd: tempRoot }; + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +console.log('\n=== Testing Santa Loop Codex model selection (stub CLI) ===\n'); + +for (const commandPath of commands) { + test(`${commandPath} has no obsolete fixed-model promise`, () => { + const source = fs.readFileSync(path.join(repoRoot, commandPath), 'utf8'); + assert.ok(!/gpt-5\.4/i.test(source), 'Codex model selection must agree throughout the command'); + }); + for (const model of ['account-default', 'configured-review-model']) { + test(`${commandPath} respects ${model} and preserves review inputs`, () => { + const result = runCodexExample(commandPath, model); + assert.strictEqual(result.model, model); + assert.strictEqual(result.args[0], 'exec'); + assert.strictEqual(result.args[result.args.indexOf('--sandbox') + 1], 'read-only'); + assert.strictEqual(result.args[result.args.indexOf('-C') + 1], result.cwd); + assert.strictEqual(result.args.at(-1), '-'); + assert.strictEqual(result.prompt, 'Independent review only.'); + }); + } +} + +console.log(`\nPassed: ${passed}`); +console.log(`Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/docs/antigravity-guide.test.js b/tests/docs/antigravity-guide.test.js index 2610f24fa..6079ca2e9 100644 --- a/tests/docs/antigravity-guide.test.js +++ b/tests/docs/antigravity-guide.test.js @@ -34,12 +34,13 @@ test('guide requires an installer with native Antigravity 2.0 support', () => { }); test('guide uses the published 2.2 package without stale pre-release copy', () => { + const publishedVersion = '2.2.3'; assert.ok( - guide.includes('npm view ecc-universal version'), - 'Guide should let operators verify registry propagation before installation' + guide.includes(`npm view ecc-universal@${publishedVersion} version`), + 'Guide should let operators verify the pinned package is available before installation' ); assert.ok( - guide.includes('npx ecc-universal@2.2.0 install --profile minimal --target antigravity'), + guide.includes(`npx ecc-universal@${publishedVersion} install --profile minimal --target antigravity`), 'Guide should provide the pinned published-package installation path' ); assert.ok( diff --git a/tests/lib/claude-config-tilde.test.js b/tests/lib/claude-config-tilde.test.js new file mode 100644 index 000000000..a2a1d919e --- /dev/null +++ b/tests/lib/claude-config-tilde.test.js @@ -0,0 +1,47 @@ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { getInstallTargetAdapter } = require('../../scripts/lib/install-targets/registry'); +const { resolveClaudePaths } = require('../../scripts/lib/install/inventory'); +const { resolveEccRoot } = require('../../scripts/lib/resolve-ecc-root'); +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-tilde-regression-')); +const home = path.join(root, 'home'); +const project = path.join(root, 'project'); +fs.mkdirSync(home); fs.mkdirSync(project); +let passed = 0; let failed = 0; +function test(name, run) { + try { run(); passed++; console.log(`PASS ${name}`); } + catch (error) { failed++; console.error(`FAIL ${name}: ${error.stack}`); } +} +try { + const adapter = getInstallTargetAdapter('claude'); + for (const [configured, relative] of [['~', ''], ['~/profile', 'profile'], ['~\\profile', 'profile'], [' ~/profile ', 'profile'], [' ', '.claude']]) { + const expected = path.join(home, relative); + test(`installer resolves ${JSON.stringify(configured)} against explicit home`, () => { + assert.strictEqual(adapter.resolveRoot({ homeDir: home, projectRoot: project, env: { CLAUDE_CONFIG_DIR: configured } }), expected); + }); + test(`inventory resolves ${JSON.stringify(configured)} against explicit home`, () => { + assert.strictEqual(resolveClaudePaths({ homeDir: home, projectRoot: project, configDir: configured }).configDir, expected); + }); + } + test('actual install from separate cwd is loadable through the runtime resolver', () => { + const result = spawnSync(process.execPath, [path.resolve(__dirname, '../../scripts/install-apply.js'), '--target', 'claude', 'typescript', '--no-hooks'], { + cwd: project, + env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: '~/profile', CLAUDE_RULES_DIR: '', CLAUDE_PLUGIN_ROOT: '' }, + encoding: 'utf8', timeout: 30000, + }); + assert.strictEqual(result.status, 0, result.stderr || result.stdout); + assert.strictEqual(fs.existsSync(path.join(project, '~')), false, 'install must not create cwd/~/profile'); + const runtimeRoot = resolveEccRoot({ envRoot: '', homeDir: home, configDir: '~/profile', probe: 'scripts/lib/utils.js' }); + assert.strictEqual(runtimeRoot, path.join(home, 'profile')); + assert.strictEqual(resolveClaudePaths({ homeDir: home, projectRoot: project, configDir: '~/profile' }).configDir, runtimeRoot); + const modulePath = path.join(runtimeRoot, 'scripts/lib/utils.js'); + assert.ok(fs.existsSync(modulePath), 'installed runtime module must exist'); + assert.strictEqual(typeof require(modulePath), 'object'); + }); +} finally { fs.rmSync(root, { recursive: true, force: true }); } +console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); +process.exitCode = failed ? 1 : 0; diff --git a/tests/lib/command-plugin-root.test.js b/tests/lib/command-plugin-root.test.js index 73cb1cf11..e9b8f93e3 100644 --- a/tests/lib/command-plugin-root.test.js +++ b/tests/lib/command-plugin-root.test.js @@ -186,6 +186,100 @@ for (const level of ['slug', 'org']) { } finally { fs.rmSync(temp, { recursive: true, force: true }); } }); } +// Regression: on WSL, CLAUDE_CONFIG_DIR may carry a Windows path (e.g. +// C:\Users\name\.claude). Every inline resolver that reads CLAUDE_CONFIG_DIR +// must include the /proc/version → /mnt/… conversion so hooks and commands +// resolve to the correct mount instead of treating the Windows path as +// relative. The source INLINE_RESOLVE, command .md files, and hooks.json / +// codex-hooks.json must all carry the same conversion block. +function countHooksWithWslConversion(filePath) { + const allowedFilePaths = new Set([ + path.resolve(__dirname, '..', '..', 'hooks', 'hooks.json'), + path.resolve(__dirname, '..', '..', 'hooks', 'codex-hooks.json'), + ]); + const resolvedPath = path.resolve(filePath); + assert.ok(allowedFilePaths.has(resolvedPath), `Unexpected hook file path: ${filePath}`); + const config = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')); + const events = config.hooks || {}; + let checked = 0; + for (const entries of Object.values(events)) { + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (!entry.hooks) continue; + for (const hook of entry.hooks) { + if (typeof hook.command !== 'string') continue; + if (hook.command.includes('CLAUDE_CONFIG_DIR')) { + assert.ok( + hook.command.includes('proc/version') && hook.command.includes('microsoft'), + `${path.basename(filePath)} hook command with CLAUDE_CONFIG_DIR must include WSL path conversion` + ); + checked++; + } + } + } + } + return checked; +} + +test('hooks.json inline resolvers include WSL path conversion for CLAUDE_CONFIG_DIR', () => { + const checked = countHooksWithWslConversion(path.join(__dirname, '..', '..', 'hooks', 'hooks.json')); + assert.ok(checked > 0, 'Expected at least one hooks.json resolver with CLAUDE_CONFIG_DIR'); +}); + +test('codex-hooks.json inline resolver includes WSL path conversion for CLAUDE_CONFIG_DIR', () => { + const checked = countHooksWithWslConversion(path.join(__dirname, '..', '..', 'hooks', 'codex-hooks.json')); + // Assert at least one resolver with WSL conversion exists. An exact count would break + // when a second hook with CLAUDE_CONFIG_DIR is added; checking the required behavior + // (all such hooks carry the conversion block) is enforced by countHooksWithWslConversion. + assert.ok(checked >= 1, 'Expected at least one codex-hooks.json resolver with CLAUDE_CONFIG_DIR WSL conversion'); +}); + +test('inline resolver copies in commands and hooks match canonical buildInlineResolve', () => { + const { INLINE_RESOLVE, buildInlineResolve } = require('../../scripts/lib/resolve-ecc-root'); + const { INLINE_WSL_CONVERSION } = require('../../scripts/lib/install-targets/helpers'); + assert.ok( + INLINE_RESOLVE.includes(INLINE_WSL_CONVERSION), + 'INLINE_RESOLVE must embed canonical INLINE_WSL_CONVERSION from helpers.js' + ); + + for (const cmdName of ['instinct-status', 'sessions', 'skill-health', 'skill-host-compat']) { + const cmdDoc = fs.readFileSync(path.join(__dirname, '..', '..', 'commands', `${cmdName}.md`), 'utf8'); + const matches = cmdDoc.match(/\(function\(\)\{var p=require\(.+?return d\}\)\(\)/g) || []; + assert.ok(matches.length > 0, `Expected inline resolver in commands/${cmdName}.md`); + for (const match of matches) { + assert.strictEqual(match, INLINE_RESOLVE, `commands/${cmdName}.md inline resolver drifted from canonical INLINE_RESOLVE`); + } + } + + const autoUpdateDoc = fs.readFileSync(path.join(__dirname, '..', '..', 'commands', 'auto-update.md'), 'utf8'); + const autoUpdateMatches = autoUpdateDoc.match(/\(function\(\)\{var p=require\(.+?return d\}\)\(\)/g) || []; + assert.strictEqual(autoUpdateMatches.length, 1, 'Expected 1 inline resolver in commands/auto-update.md'); + assert.strictEqual( + autoUpdateMatches[0], + buildInlineResolve({ probe: 'scripts/auto-update.js' }), + 'commands/auto-update.md inline resolver drifted from canonical buildInlineResolve' + ); + + for (const hookFile of ['hooks.json', 'codex-hooks.json']) { + const config = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'hooks', hookFile), 'utf8')); + let verified = 0; + for (const entries of Object.values(config.hooks || {})) { + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + for (const hook of entry.hooks || []) { + if (typeof hook.command === 'string' && hook.command.includes('CLAUDE_CONFIG_DIR')) { + const m = hook.command.match(/const r=(\(function\(\)\{.*?\}\)\(\))/); + assert.ok(m, `Could not extract inline resolver from ${hookFile}`); + assert.strictEqual(m[1], INLINE_RESOLVE, `${hookFile} inline resolver drifted from canonical INLINE_RESOLVE`); + verified++; + } + } + } + } + assert.ok(verified > 0, `Expected at least one verified resolver in ${hookFile}`); + } +}); + console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); diff --git a/tests/lib/extracted-resolver-wsl.test.js b/tests/lib/extracted-resolver-wsl.test.js new file mode 100644 index 000000000..2b25ce437 --- /dev/null +++ b/tests/lib/extracted-resolver-wsl.test.js @@ -0,0 +1,139 @@ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +let passed = 0; +let failed = 0; +function test(name, run) { + try { run(); passed++; console.log(`PASS ${name}`); } + catch (error) { failed++; console.error(`FAIL ${name}: ${error.stack}`); } +} +// These tests execute extracted JavaScript with simulated POSIX path/OS/filesystem APIs. +// Serialized Bash quoting is covered separately by serialized-hook-wsl.test.js. +test('INLINE_RESOLVE extracted expression converts Windows CLAUDE_CONFIG_DIR on simulated WSL', () => { + const { INLINE_RESOLVE } = require('../../scripts/lib/resolve-ecc-root'); + // INLINE_RESOLVE is formatted for embedding inside bash double-quoted `node -e "..."` strings, + // where `\\\\` is unescaped by the shell to `\\` before Node parses the JS source. + const jsResolveExpr = INLINE_RESOLVE.replace(/\\\\\\\\/g, '\\\\'); + const scriptContent = ` + 'use strict'; + const Module = require('module'); + const realOs = require('os'); + const realFs = require('fs'); + const posixPath = require('path').posix; + const mockedFs = new Proxy(realFs, { + get(target, prop) { + if (prop === 'readdirSync') return () => []; + if (prop === 'readFileSync') { + return function(fp, enc) { + if (fp === '/proc/version') return 'Linux version 5.15.0-microsoft-standard-WSL2'; + return target.readFileSync(fp, enc); + }; + } + return typeof target[prop] === 'function' ? target[prop].bind(target) : target[prop]; + }, + }); + const mockedOs = new Proxy(realOs, { + get(target, prop) { + if (prop === 'platform') return () => 'linux'; + if (prop === 'homedir') return () => '/home/wsluser'; + return typeof target[prop] === 'function' ? target[prop].bind(target) : target[prop]; + }, + }); + const originalLoad = Module._load; + Module._load = function(request, parent, isMain) { + if (request === 'fs') return mockedFs; + if (request === 'os') return mockedOs; + if (request === 'path') return posixPath; + if (request.endsWith('/scripts/lib/resolve-ecc-root')) { + const error = new Error('No installed resolver in this fixture'); + error.code = 'MODULE_NOT_FOUND'; + throw error; + } + return originalLoad.call(this, request, parent, isMain); + }; + process.env.CLAUDE_PLUGIN_ROOT = ''; + process.env.CLAUDE_CONFIG_DIR = 'C:\\\\Users\\\\x\\\\.claude'; + const result = ${jsResolveExpr}; + process.stdout.write(result + '\\n'); + `; + + const result = spawnSync(process.execPath, ['-e', scriptContent], { encoding: 'utf8', timeout: 10000, env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot } }); + assert.strictEqual(result.status, 0, `Resolver subprocess failed: ${result.stderr}`); + assert.strictEqual(result.stdout.trim(), '/mnt/c/Users/x/.claude'); +}); + +test('codex-hooks.json extracted resolver converts Windows CLAUDE_CONFIG_DIR to /mnt/... on simulated WSL', () => { + const config = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'hooks', 'codex-hooks.json'), 'utf8')); + const hookEvents = config.hooks || {}; + + let hookResolverJS = null; + outer: for (const entries of Object.values(hookEvents)) { + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (!entry.hooks) continue; + for (const hook of entry.hooks) { + if (typeof hook.command !== 'string') continue; + if (hook.command.includes('CLAUDE_CONFIG_DIR') && hook.command.includes('proc/version')) { + const m = hook.command.match(/const r=(\(function\(\)\{.*?\}\)\(\))/s); + if (m) { hookResolverJS = m[1]; break outer; } + } + } + } + } + assert.ok(hookResolverJS, 'Could not extract inline resolver IIFE from codex-hooks.json'); + const jsResolveExpr = hookResolverJS.replace(/\\\\\\\\/g, '\\\\'); + + const scriptContent = ` + 'use strict'; + const Module = require('module'); + const realOs = require('os'); + const realFs = require('fs'); + const posixPath = require('path').posix; + const mockedFs = new Proxy(realFs, { + get(target, prop) { + if (prop === 'readdirSync') return () => []; + if (prop === 'readFileSync') { + return function(fp, enc) { + if (fp === '/proc/version') return 'Linux version 5.15.0-microsoft-standard-WSL2'; + return target.readFileSync(fp, enc); + }; + } + return typeof target[prop] === 'function' ? target[prop].bind(target) : target[prop]; + }, + }); + const mockedOs = new Proxy(realOs, { + get(target, prop) { + if (prop === 'platform') return () => 'linux'; + if (prop === 'homedir') return () => '/home/wsluser'; + return typeof target[prop] === 'function' ? target[prop].bind(target) : target[prop]; + }, + }); + const originalLoad = Module._load; + Module._load = function(request, parent, isMain) { + if (request === 'fs') return mockedFs; + if (request === 'os') return mockedOs; + if (request === 'path') return posixPath; + if (request.endsWith('/scripts/lib/resolve-ecc-root')) { + const error = new Error('No installed resolver in this fixture'); + error.code = 'MODULE_NOT_FOUND'; + throw error; + } + return originalLoad.call(this, request, parent, isMain); + }; + process.env.CLAUDE_PLUGIN_ROOT = ''; + process.env.CLAUDE_CONFIG_DIR = 'C:\\\\Users\\\\test\\\\.claude'; + const r = ${jsResolveExpr}; + process.stdout.write(r + '\\n'); + `; + + const result = spawnSync(process.execPath, ['-e', scriptContent], { encoding: 'utf8', timeout: 10000, env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot } }); + assert.strictEqual(result.status, 0, `Hook resolver subprocess failed: ${result.stderr}`); + assert.strictEqual(result.stdout.trim(), '/mnt/c/Users/test/.claude'); +}); + + +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); +process.exitCode = failed ? 1 : 0; diff --git a/tests/lib/inline-fallback-shell.test.js b/tests/lib/inline-fallback-shell.test.js new file mode 100644 index 000000000..54c7ed9f9 --- /dev/null +++ b/tests/lib/inline-fallback-shell.test.js @@ -0,0 +1,34 @@ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const env = { PATH: [path.dirname(process.execPath), '/usr/bin', '/bin'].join(path.delimiter), HOME: os.tmpdir() }; +if (process.platform === 'win32' || spawnSync('bash', ['--version'], { env }).status !== 0) { + console.log('SKIP helper-unavailable serialized command requires POSIX Bash'); +} else { + const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-inline-fallback-')); + let failed = 0; + try { + const standalone = path.join(folder, 'resolve-ecc-root.js'); + fs.copyFileSync(path.resolve(__dirname, '../../scripts/lib/resolve-ecc-root.js'), standalone); + assert.ok(!fs.existsSync(path.join(folder, 'install-targets')), 'Helper must be absent'); + const expression = require(standalone).INLINE_RESOLVE; + const command = 'node -e "const root=' + expression + ';process.stdout.write(String(root))"'; + const result = spawnSync('bash', ['-c', command], { + cwd: folder, timeout: 10000, encoding: 'utf8', + env: { ...env, HOME: folder, CLAUDE_PLUGIN_ROOT: folder }, + }); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, folder); + console.log('PASS helper-unavailable expression survives Bash quoting and returns explicit root'); + } catch (error) { + failed++; console.error(error.stack); + } finally { + fs.rmSync(folder, { recursive: true, force: true }); + } + console.log('Passed: ' + (failed ? 0 : 1)); + console.log('Failed: ' + failed); + process.exitCode = failed ? 1 : 0; +} diff --git a/tests/lib/inspection.test.js b/tests/lib/inspection.test.js index f8725f37c..67834ad4b 100644 --- a/tests/lib/inspection.test.js +++ b/tests/lib/inspection.test.js @@ -8,6 +8,7 @@ const { normalizeFailureReason, groupFailures, detectPatterns, + inspect, generateReport, suggestAction, DEFAULT_FAILURE_THRESHOLD, @@ -58,15 +59,85 @@ async function runTests() { })) passed += 1; else failed += 1; if (await test('normalizeFailureReason strips file paths', async () => { - const normalized = normalizeFailureReason('File not found: /usr/local/bin/node'); - assert.ok(!normalized.includes('/usr/local')); - assert.ok(normalized.includes('')); + for (const reason of [ + 'File not found: /usr/local/bin/node', + String.raw`File not found: C:\project-a\src\one.js`, + 'File not found: D:/project-b/src/two.js', + String.raw`File not found: \\server\share\three.js`, + String.raw`File not found: C:/project-a\src\one.js`, + String.raw`File not found: D:\project-b/src/two.js`, + 'File not found: C:\\project-a\\', + 'File not found: D:/project-b/', + 'File not found: \\\\server\\share\\', + ]) { + assert.strictEqual(normalizeFailureReason(reason), 'file not found: '); + } + assert.strictEqual(normalizeFailureReason('Request failed: https://example.com/test'), 'request failed: https:'); })) passed += 1; else failed += 1; if (await test('normalizeFailureReason handles null and empty values', async () => { assert.strictEqual(normalizeFailureReason(null), 'unknown'); assert.strictEqual(normalizeFailureReason(''), 'unknown'); assert.strictEqual(normalizeFailureReason(undefined), 'unknown'); + assert.strictEqual(normalizeFailureReason(123), 'unknown'); + })) passed += 1; else failed += 1; + + if (await test('normalizeFailureReason strips quoted Windows paths with spaces and Unicode', async () => { + for (const filePath of [ + String.raw`C:\Usuarios\José Pérez\mi archivo.js`, + String.raw`D:\用户\张三\我的 文件.js`, + String.raw`\\servidor\recurso compartido\José\mi archivo.js`, + String.raw`C:\Program Files\my node`, + ]) { + for (const quote of ['"', "'"]) { + assert.strictEqual(normalizeFailureReason(`Cannot open ${quote}${filePath}${quote}: permission denied`), + `cannot open ${quote}${quote}: permission denied`); + } + } + })) passed += 1; else failed += 1; + + if (await test('normalizeFailureReason keeps apostrophes inside quoted Windows paths', async () => { + for (const filePath of [ + String.raw`C:\Users\O'Brien\report.js`, + String.raw`D:\Users\D'Arcy\my report.js`, + String.raw`\\server\shared files\L'Heureux\report.js`, + ]) { + assert.strictEqual(normalizeFailureReason(`Cannot open '${filePath}': permission denied`), + "cannot open '': permission denied"); + } + assert.notStrictEqual( + normalizeFailureReason(String.raw`Cannot open 'C:\Users\O'Brien\report.js': permission denied`), + normalizeFailureReason(String.raw`Cannot open 'C:\Users\O'Brien\report.js': file missing`) + ); + })) passed += 1; else failed += 1; + + if (await test('normalizeFailureReason preserves diagnostics after unquoted Windows paths', async () => { + for (const filePath of [ + String.raw`C:\Program Files\node`, + 'D:/Program Files/node', + String.raw`\\server\shared files\node`, + String.raw`C:\Users\Alice Smith\my file.js`, + 'D:/Users/Bob Jones/your script.js', + String.raw`\\server\shared files\other task.js`, + String.raw`C:\work\my alpha.js~`, + 'D:/work/my beta.py#', + String.raw`E:\work\my gamma.c++`, + ]) { + assert.strictEqual(normalizeFailureReason(`${filePath}: permission denied`), ': permission denied'); + } + assert.strictEqual(normalizeFailureReason('Request failed: https://example.com/node'), 'request failed: https:'); + assert.strictEqual(normalizeFailureReason('Cannot run /usr/local/bin/node: permission denied'), 'cannot run : permission denied'); + assert.strictEqual(normalizeFailureReason('Cannot open D:/Users/Alice Smith/one.js permission denied in /tmp/log'), + 'cannot open permission denied in '); + assert.strictEqual(normalizeFailureReason('Cannot run C:/tools/node failed to open /tmp/log'), + 'cannot run failed to open '); + assert.strictEqual(normalizeFailureReason('D:/Program Files/node permission denied in /tmp/log'), + ' permission denied in '); + assert.strictEqual(normalizeFailureReason('Cannot run C:/tools/node failed to open /tmp/log: permission denied'), + 'cannot run failed to open : permission denied'); + const denied = normalizeFailureReason(String.raw`C:\Program Files\node permission denied: EACCES`); + assert.strictEqual(denied, ' permission denied: eacces'); + assert.notStrictEqual(denied, normalizeFailureReason(String.raw`C:\Program Files\node: EACCES`)); })) passed += 1; else failed += 1; if (await test('groupFailures groups by skillId and normalized reason', async () => { @@ -89,6 +160,49 @@ async function runTests() { assert.strictEqual(skillBGroup.runs.length, 1); })) passed += 1; else failed += 1; + if (await test('keeps distinct diagnostics after extensionless Windows paths separate', async () => { + const runs = [ + String.raw`Command C:\tools\node exited with code: 1`, + String.raw`Command D:\tools\node exited with signal: 1`, + String.raw`Command E:\tools\node failed with status: 1`, + ].map((failureReason, index) => makeSkillRun({ id: `diagnostic-${index}`, failureReason })); + const patterns = detectPatterns(runs); + assert.strictEqual(generateReport(patterns).status, 'clean'); + assert.deepStrictEqual(patterns, []); + assert.strictEqual(groupFailures(runs).size, 3); + })) passed += 1; else failed += 1; + + if (await test('keeps dotted diagnostic prose after extensionless Windows paths separate', async () => { + const reasons = [ + String.raw`Command C:\tools\node failed to open config.json: EACCES`, + String.raw`Command D:\tools\node timed out opening config.json: EACCES`, + ]; + assert.deepStrictEqual(reasons.map(normalizeFailureReason), [ + 'command failed to open config.json: eacces', + 'command timed out opening config.json: eacces', + ]); + const runs = reasons.map((failureReason, index) => makeSkillRun({ id: `dotted-${index}`, failureReason })); + assert.strictEqual(groupFailures(runs).size, 2); + })) passed += 1; else failed += 1; + + if (await test('groups Windows filenames with diagnostic-looking words', async () => { + const reasons = [ + String.raw`Command C:\work\app error.log: permission denied`, + String.raw`Command D:\work\app output.log: permission denied`, + String.raw`Command E:\work\my failed-test.js: permission denied`, + ]; + assert.deepStrictEqual(reasons.map(normalizeFailureReason), [ + 'command : permission denied', + 'command : permission denied', + 'command : permission denied', + ]); + const runs = reasons.map((failureReason, index) => makeSkillRun({ id: `filename-${index}`, failureReason })); + assert.strictEqual(groupFailures(runs).size, 1); + const patterns = detectPatterns(runs); + assert.strictEqual(patterns.length, 1); + assert.strictEqual(patterns[0].count, 3); + })) passed += 1; else failed += 1; + if (await test('groupFailures handles mixed outcome casing', async () => { const runs = [ makeSkillRun({ id: 'r1', outcome: 'FAILURE', failureReason: 'timeout' }), @@ -129,6 +243,56 @@ async function runTests() { assert.strictEqual(patterns[0].runIds.length, 3); })) passed += 1; else failed += 1; + if (await test('detectPatterns orders ISO timestamps by their actual instants', async () => { + const older = '2026-03-15T04:00:00+04:00'; + const newer = '2026-03-15T01:00:00Z'; + const runs = [ + makeSkillRun({ id: 'old', skillId: 'alpha', failureReason: 'error', createdAt: older }), + makeSkillRun({ id: 'new', skillId: 'alpha', failureReason: 'error', createdAt: newer }), + makeSkillRun({ id: 'b1', skillId: 'beta', failureReason: 'error', createdAt: '2026-03-15T10:15:00+10:00' }), + makeSkillRun({ id: 'b2', skillId: 'beta', failureReason: 'error', createdAt: '2026-03-15T10:30:00+10:00' }), + { ...makeSkillRun({ id: 'g1', skillId: 'gamma', failureReason: 'error' }), createdAt: null }, + { ...makeSkillRun({ id: 'g2', skillId: 'gamma', failureReason: 'error' }), createdAt: null }, + ]; + const patterns = detectPatterns(runs, { threshold: 2 }); + assert.deepStrictEqual(patterns.map(pattern => pattern.skillId), ['alpha', 'beta', 'gamma']); + assert.strictEqual(patterns[0].firstSeen, older); + assert.strictEqual(patterns[0].lastSeen, newer); + assert.deepStrictEqual(patterns[0].runIds, ['new', 'old']); + assert.strictEqual(patterns[2].firstSeen, null); + assert.strictEqual(patterns[2].lastSeen, null); + })) passed += 1; else failed += 1; + + if (await test('detectPatterns keeps undated runs and patterns stable after dated ones', async () => { + const historical = '1969-12-31T23:59:59Z'; + const epoch = '1970-01-01T00:00:00Z'; + const runs = [ + { ...makeSkillRun({ id: 'missing-a' }), createdAt: null }, + makeSkillRun({ id: 'historical', createdAt: historical }), + { ...makeSkillRun({ id: 'missing-b' }), createdAt: null }, + makeSkillRun({ id: 'epoch', createdAt: epoch }), + ]; + + const [pattern] = detectPatterns(runs, { threshold: 2 }); + assert.deepStrictEqual(pattern.runIds, ['epoch', 'historical', 'missing-a', 'missing-b']); + assert.strictEqual(pattern.lastSeen, epoch); + const patternRuns = [ + { ...makeSkillRun({ id: 'a1', skillId: 'undated-a' }), createdAt: null }, + { ...makeSkillRun({ id: 'a2', skillId: 'undated-a' }), createdAt: null }, + makeSkillRun({ id: 'h1', skillId: 'historical', createdAt: '1969-12-31T23:59:58Z' }), + makeSkillRun({ id: 'h2', skillId: 'historical', createdAt: '1969-12-31T23:59:59Z' }), + { ...makeSkillRun({ id: 'b1', skillId: 'undated-b' }), createdAt: null }, + { ...makeSkillRun({ id: 'b2', skillId: 'undated-b' }), createdAt: null }, + ]; + + const patterns = detectPatterns(patternRuns, { threshold: 2 }); + assert.deepStrictEqual(patterns.map(pattern => pattern.skillId), ['historical', 'undated-a', 'undated-b']); + assert.deepStrictEqual(patterns[1].runIds, ['a1', 'a2']); + assert.deepStrictEqual(patterns[2].runIds, ['b1', 'b2']); + assert.strictEqual(patterns[1].firstSeen, null); + assert.strictEqual(patterns[1].lastSeen, null); + })) passed += 1; else failed += 1; + if (await test('detectPatterns uses default threshold', async () => { const runs = Array.from({ length: DEFAULT_FAILURE_THRESHOLD }, (_, i) => makeSkillRun({ id: `r${i}`, failureReason: 'permission denied' }) @@ -171,6 +335,48 @@ async function runTests() { assert.ok(patterns[0].normalizedReason.includes('')); })) passed += 1; else failed += 1; + if (await test('detectPatterns groups failures from different Windows paths', async () => { + const runs = [ + makeSkillRun({ id: 'r1', failureReason: String.raw`Cannot open C:\Users\Alice Smith\my file.js: permission denied` }), + makeSkillRun({ id: 'r2', failureReason: 'Cannot open D:/Users/Bob Jones/your script.js: permission denied' }), + makeSkillRun({ id: 'r3', failureReason: String.raw`Cannot open E:\Users\Carol White\other task.js: permission denied` }), + ]; + const patterns = detectPatterns(runs); + assert.strictEqual(patterns.length, 1); + assert.strictEqual(patterns[0].count, 3); + assert.strictEqual(patterns[0].normalizedReason, 'cannot open : permission denied'); + assert.strictEqual(inspect({ getStatus: () => ({ skillRuns: { recent: runs } }) }).status, 'attention_needed'); + const directoryRuns = ['C:\\project-a\\', 'D:/project-b/', '\\\\server\\share\\'].map((filePath, index) => + makeSkillRun({ id: `directory-${index}`, failureReason: `Cannot open ${filePath}` })); + const directoryPatterns = detectPatterns(directoryRuns); + assert.strictEqual(directoryPatterns.length, 1); + assert.strictEqual(directoryPatterns[0].count, 3); + assert.strictEqual(generateReport(directoryPatterns).status, 'attention_needed'); + const backupRuns = [ + String.raw`C:\work\my alpha.js~`, + 'D:/work/my beta.js~', + String.raw`\\server\share\my gamma.js~`, + ].map((filePath, index) => makeSkillRun({ id: `backup-${index}`, failureReason: `Cannot open ${filePath}: permission denied` })); + const backupPatterns = detectPatterns(backupRuns); + assert.strictEqual(backupPatterns.length, 1); + assert.strictEqual(backupPatterns[0].count, 3); + assert.strictEqual(generateReport(backupPatterns).status, 'attention_needed'); + })) passed += 1; else failed += 1; + + if (await test('inspect groups quoted Windows paths with apostrophes and preserves diagnostics', async () => { + const runs = [ + String.raw`Cannot open 'C:\Users\O'Brien\report.js': permission denied`, + String.raw`Cannot open 'D:\Users\D'Arcy\my report.js': permission denied`, + String.raw`Cannot open '\\server\shared files\L'Heureux\report.js': permission denied`, + ].map((failureReason, index) => makeSkillRun({ id: `apostrophe-${index}`, failureReason })); + const report = inspect({ getStatus: () => ({ skillRuns: { recent: runs } }) }); + assert.strictEqual(report.status, 'attention_needed'); + assert.strictEqual(report.patternCount, 1); + assert.strictEqual(report.patterns[0].count, 3); + assert.strictEqual(report.patterns[0].normalizedReason, "cannot open '': permission denied"); + assert.deepStrictEqual(report.patterns[0].rawReasons, runs.map(run => run.failureReason)); + })) passed += 1; else failed += 1; + if (await test('detectPatterns tracks unique session IDs and versions', async () => { const runs = [ makeSkillRun({ id: 'r1', sessionId: 'sess-1', skillVersion: '1.0.0', failureReason: 'err' }), @@ -190,6 +396,7 @@ async function runTests() { assert.strictEqual(report.patternCount, 0); assert.ok(report.summary.includes('No recurring')); assert.ok(report.generatedAt); + assert.strictEqual(inspect({ getStatus: () => ({ skillRuns: {} }) }, { threshold: 1, windowSize: 1 }).status, 'clean'); })) passed += 1; else failed += 1; if (await test('generateReport produces structured report from patterns', async () => { diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index 68cedc557..6eecbed65 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -66,7 +66,7 @@ function runTests() { if (test('resolves claude adapter root and install-state path from home dir', () => { const adapter = getInstallTargetAdapter('claude'); - const homeDir = '/Users/example'; + const homeDir = path.resolve('/Users/example'); const root = adapter.resolveRoot({ homeDir, repoRoot: '/repo/ecc' }); const statePath = adapter.getInstallStatePath({ homeDir, repoRoot: '/repo/ecc' }); @@ -163,7 +163,7 @@ function runTests() { if (test('plans namespaced Claude rules and flat discoverable skills', () => { const repoRoot = path.join(__dirname, '..', '..'); - const homeDir = '/Users/example'; + const homeDir = path.resolve('/Users/example'); const plan = planInstallTargetScaffold({ target: 'claude', @@ -705,6 +705,61 @@ function runTests() { ); })) passed++; else failed++; + if (test('claude adapter honors CLAUDE_CONFIG_DIR over the default home directory', () => { + const adapter = getInstallTargetAdapter('claude'); + const homeDir = '/Users/example'; + const claudeConfigDir = '/private/claude-profile'; + + assert.strictEqual( + adapter.resolveRoot({ homeDir, env: { CLAUDE_CONFIG_DIR: claudeConfigDir } }), + path.resolve(claudeConfigDir) + ); + assert.strictEqual( + adapter.getInstallStatePath({ homeDir, env: { CLAUDE_CONFIG_DIR: claudeConfigDir } }), + path.join(path.resolve(claudeConfigDir), 'ecc', 'install-state.json') + ); + })) passed++; else failed++; + + if (test('claude adapter falls back to home/.claude when CLAUDE_CONFIG_DIR is unset or blank', () => { + const adapter = getInstallTargetAdapter('claude'); + const homeDir = path.resolve('/Users/example'); + + assert.strictEqual( + adapter.resolveRoot({ homeDir, env: {} }), + path.join(homeDir, '.claude') + ); + assert.strictEqual( + adapter.resolveRoot({ homeDir, env: { CLAUDE_CONFIG_DIR: ' ' } }), + path.join(homeDir, '.claude') + ); + })) passed++; else failed++; + + if (test('plans claude install under CLAUDE_CONFIG_DIR, keeping operations in the redirected root', () => { + const repoRoot = path.join(__dirname, '..', '..'); + const homeDir = '/Users/example'; + const claudeConfigDir = '/private/claude-profile'; + + const plan = planInstallTargetScaffold({ + target: 'claude', + repoRoot, + homeDir, + env: { CLAUDE_CONFIG_DIR: claudeConfigDir }, + modules: [ + { id: 'rules-core', paths: ['rules'] }, + { id: 'docs-core', paths: ['docs'] }, + ], + }); + + assert.strictEqual(plan.targetRoot, path.resolve(claudeConfigDir)); + assert.ok( + plan.operations.every(operation => ( + operation.destinationPath === plan.targetRoot + || operation.destinationPath.startsWith(`${plan.targetRoot}${path.sep}`) + )), + 'Should keep every managed operation inside the CLAUDE_CONFIG_DIR root' + ); + })) passed++; else failed++; + if (test('invocation environments are immutable snapshots', () => { const source = { OPENCODE_CONFIG_DIR: '/custom/opencode' }; const selected = resolveInvocationEnvironment({ env: source }); @@ -1495,6 +1550,60 @@ function runTests() { } })) passed++; else failed++; + if (test('convertWindowsPathToWSL converts Windows paths on WSL', () => { + const { convertWindowsPathToWSL, resetWSLDetection, setWSLForTesting } = require('../../scripts/lib/install-targets/helpers'); + setWSLForTesting(true); + try { + assert.strictEqual(convertWindowsPathToWSL('C:\\Users\\test\\.claude'), '/mnt/c/Users/test/.claude'); + assert.strictEqual(convertWindowsPathToWSL('D:/Projects'), '/mnt/d/Projects'); + assert.strictEqual(convertWindowsPathToWSL('C:\\Path\\With\\Backslashes'), '/mnt/c/Path/With/Backslashes'); + } finally { + resetWSLDetection(); + } + })) passed++; else failed++; + + if (test('convertWindowsPathToWSL returns original path when not on WSL', () => { + const { convertWindowsPathToWSL, resetWSLDetection, setWSLForTesting } = require('../../scripts/lib/install-targets/helpers'); + setWSLForTesting(false); + try { + assert.strictEqual(convertWindowsPathToWSL('C:\\Users\\test\\.claude'), 'C:\\Users\\test\\.claude'); + assert.strictEqual(convertWindowsPathToWSL('/home/user/.claude'), '/home/user/.claude'); + } finally { + resetWSLDetection(); + } + })) passed++; else failed++; + + if (test('convertWindowsPathToWSL handles non-string input', () => { + const { convertWindowsPathToWSL, resetWSLDetection, setWSLForTesting } = require('../../scripts/lib/install-targets/helpers'); + setWSLForTesting(true); + try { + assert.strictEqual(convertWindowsPathToWSL(null), null); + assert.strictEqual(convertWindowsPathToWSL(undefined), undefined); + assert.strictEqual(convertWindowsPathToWSL(123), 123); + } finally { + resetWSLDetection(); + } + })) passed++; else failed++; + + if (test('claude adapter converts CLAUDE_CONFIG_DIR Windows path to WSL path on WSL', () => { + const adapter = getInstallTargetAdapter('claude'); + const homeDir = '/home/user'; + const windowsConfigDir = 'C:\\Users\\test\\.claude'; + const expectedWslPath = '/mnt/c/Users/test/.claude'; + + // Mock WSL detection + const { setWSLForTesting } = require('../../scripts/lib/install-targets/helpers'); + setWSLForTesting(true); + try { + const resolved = adapter.resolveRoot({ homeDir, env: { CLAUDE_CONFIG_DIR: windowsConfigDir } }); + assert.strictEqual(resolved, path.resolve(expectedWslPath)); + const statePath = adapter.getInstallStatePath({ homeDir, env: { CLAUDE_CONFIG_DIR: windowsConfigDir } }); + assert.strictEqual(statePath, path.join(path.resolve(expectedWslPath), 'ecc', 'install-state.json')); + } finally { + setWSLForTesting(null); + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } diff --git a/tests/lib/resolve-ecc-root.test.js b/tests/lib/resolve-ecc-root.test.js index f532eba98..3b7a8bad3 100644 --- a/tests/lib/resolve-ecc-root.test.js +++ b/tests/lib/resolve-ecc-root.test.js @@ -534,6 +534,50 @@ module.exports = { resolveEccRoot() { assert.strictEqual(process.env.HOME, ${JSO } })) passed++; else failed++; + // ─── WSL Path Conversion ─── + + if (test('resolveEccRoot converts Windows CLAUDE_CONFIG_DIR to WSL path on WSL', () => { + const { setWSLForTesting, resetWSLDetection } = require('../../scripts/lib/install-targets/helpers'); + setWSLForTesting(true); + try { + const homeDir = createTempDir(); + try { + const result = resolveEccRoot({ + homeDir, + configDir: 'C:\\Users\\test\\.claude' + }); + // On WSL, 'C:\\Users\\test\\.claude' is converted to '/mnt/c/Users/test/.claude'. + // No install exists at the converted path, so resolveEccRoot falls back to it. + assert.strictEqual(result, path.resolve('/mnt/c/Users/test/.claude')); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + } finally { + resetWSLDetection(); + } + })) passed++; else failed++; + + if (test('resolveEccRoot returns original path when not on WSL', () => { + const { setWSLForTesting, resetWSLDetection } = require('../../scripts/lib/install-targets/helpers'); + setWSLForTesting(false); + try { + const homeDir = createTempDir(); + try { + const result = resolveEccRoot({ + homeDir, + configDir: 'C:\\Users\\test\\.claude' + }); + // Not on WSL: Windows path is not converted. On Linux, path.resolve treats + // it as a relative path, resolving against the current working directory. + assert.strictEqual(result, path.resolve('C:\\Users\\test\\.claude')); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + } finally { + resetWSLDetection(); + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } diff --git a/tests/lib/serialized-hook-wsl.test.js b/tests/lib/serialized-hook-wsl.test.js new file mode 100644 index 000000000..55854e476 --- /dev/null +++ b/tests/lib/serialized-hook-wsl.test.js @@ -0,0 +1,81 @@ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +if (process.platform === 'win32') { + console.log('SKIP serialized WSL command requires POSIX Node and Bash; portable extracted resolver tests cover conversion'); +} else { +const config = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../hooks/hooks.json'), 'utf8')); +const command = config.hooks.SessionStart.flatMap(entry => entry.hooks || []) + .map(hook => hook.command).find(value => typeof value === 'string' && value.includes('CLAUDE_CONFIG_DIR') && value.endsWith('node scripts/hooks/session-start-bootstrap.js')); +assert.ok(command, 'Expected the shipped SessionStart bootstrap command'); +const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-shell-wsl-')); +const preload = path.join(folder, 'preload.cjs'); +fs.writeFileSync(preload, ` +const Module = require('module'); +const fs = require('fs'); +const os = require('os'); +const originalLoad = Module._load; +const simulatedFs = new Proxy(fs, { get(target, key) { + if (key === 'readFileSync') return (file, ...args) => file === '/proc/version' + ? 'Linux microsoft-standard-WSL2' : target.readFileSync(file, ...args); + if (key === 'readdirSync') return (file, ...args) => { + if (String(file).startsWith('/mnt/c')) { + const error = new Error('Isolated fixture has no plugin cache'); error.code = 'ENOENT'; throw error; + } + return target.readdirSync(file, ...args); + }; + return target[key]; +}}); +const simulatedOs = new Proxy(os, { get(target, key) { + if (key === 'platform') return () => 'linux'; + if (key === 'homedir') return () => '/home/wsluser'; + return target[key]; +}}); +Module._load = function(request, parent, isMain) { + if (request === 'fs') return simulatedFs; + if (request === 'os') return simulatedOs; + if (request.startsWith('/mnt/c') && request.endsWith('/scripts/lib/resolve-ecc-root')) { + const error = new Error('Isolated fixture has no installed resolver'); error.code = 'MODULE_NOT_FOUND'; throw error; + } + // Observe the resolved bootstrap and forwarded arguments without running host hooks. + if (request.endsWith('/scripts/hooks/plugin-hook-bootstrap.js')) { + process.stdout.write(JSON.stringify({root: process.env.CLAUDE_PLUGIN_ROOT, + bootstrap: request, args: process.argv.slice(2)}) + '\\n'); + return {}; + } + return originalLoad.call(this, request, parent, isMain); +}; +`); +function execute(serializedCommand) { + const result = spawnSync('bash', ['-c', serializedCommand], { + cwd: folder, timeout: 10000, encoding: 'utf8', + env: { PATH: [path.dirname(process.execPath), '/usr/bin', '/bin'].join(path.delimiter), + NODE_OPTIONS: `--require=${JSON.stringify(preload)}`, HOME: folder, USERPROFILE: folder, + CLAUDE_CONFIG_DIR: 'C:\\Users\\test\\.claude', CLAUDE_PLUGIN_ROOT: '', PLUGIN_ROOT: '' }, + }); + assert.strictEqual(result.status, 0, result.stderr); + return JSON.parse(result.stdout.trim()); +} +let failed = 0; +try { + const expected = '/mnt/c/Users/test/.claude'; + const actual = execute(command); + assert.strictEqual(actual.root, expected); + assert.strictEqual(actual.bootstrap, expected + '/scripts/hooks/plugin-hook-bootstrap.js'); + assert.deepStrictEqual(actual.args, ['node', 'scripts/hooks/session-start-bootstrap.js']); + console.log('PASS full JSON-decoded SessionStart hook resolves exact WSL path through Bash and forwards continuation'); + const mutation = command.replace(",'/'", ",'_'"); + assert.notStrictEqual(mutation, command, 'Mutation must modify the shipped replacement'); + const broken = execute(mutation); + assert.notStrictEqual(broken.root, expected, 'Mutation must be detected by exact expected-path assertion'); + console.log('PASS exact path assertion detects a mutated slash replacement'); +} catch (error) { failed++; console.error(error.stack); } +finally { fs.rmSync(folder, { recursive: true, force: true }); } +console.log('Passed: ' + (failed ? 0 : 2)); +console.log('Failed: ' + failed); +process.exitCode = failed ? 1 : 0; + +} diff --git a/tests/lib/session-aliases.test.js b/tests/lib/session-aliases.test.js index aaf31183a..d1ade7036 100644 --- a/tests/lib/session-aliases.test.js +++ b/tests/lib/session-aliases.test.js @@ -141,6 +141,19 @@ function runTests() { assert.ok(result.error.includes('empty')); })) passed++; else failed++; + if (test('rejects invalid titles without corrupting an existing alias', () => { + resetAliases(); + aliases.setAlias('keep', '/path/to/session', 'Original title'); + const before = fs.readFileSync(aliases.getAliasesPath(), 'utf8'); + for (const title of [42, 0, false, {}, []]) { + const result = aliases.setAlias('keep', '/different/path', title); + assert.strictEqual(result.success, false); + assert.strictEqual(result.error, 'Title must be a string or null'); + assert.strictEqual(fs.readFileSync(aliases.getAliasesPath(), 'utf8'), before); + } + assert.deepStrictEqual(aliases.listAliases({ search: 'zzz' }), []); + })) passed++; else failed++; + if (test('accepts underscores and dashes in alias', () => { resetAliases(); const result = aliases.setAlias('my_session-v2', '/path'); @@ -1287,32 +1300,18 @@ function runTests() { 'Alias should no longer exist after removal'); })) passed++; else failed++; - // ── Round 102: setAlias with title=0 (falsy number coercion) ── - console.log('\nRound 102: setAlias (title=0 — falsy coercion silently converts to null):'); - if (test('setAlias with title=0 stores null (0 || null === null due to JavaScript falsy coercion)', () => { - // session-aliases.js line 221: `title: title || null` — the value 0 is falsy - // in JavaScript, so `0 || null` evaluates to `null`. This means numeric - // titles like 0 are silently discarded. + // ── Round 102: setAlias with title=0 (invalid title type) ── + console.log('\nRound 102: setAlias (title=0 is rejected):'); + if (test('setAlias rejects a zero numeric title instead of silently storing null', () => { resetAliases(); const result = aliases.setAlias('zero-title', '/sessions/test', 0); - assert.strictEqual(result.success, true, - 'setAlias should succeed (0 is valid as a truthy check bypass)'); - assert.strictEqual(result.title, null, - 'Title should be null because 0 || null === null (falsy coercion)'); - const resolved = aliases.resolveAlias('zero-title'); - assert.strictEqual(resolved.title, null, - 'Persisted title should be null after round-trip through saveAliases/loadAliases'); + assert.strictEqual(result.success, false); + assert.strictEqual(result.error, 'Title must be a string or null'); + assert.strictEqual(aliases.resolveAlias('zero-title'), null); })) passed++; else failed++; - // ── Round 103: loadAliases with array aliases in JSON (typeof [] === 'object' bypass) ── - console.log('\nRound 103: loadAliases (array aliases — typeof bypass):'); - if (test('loadAliases accepts array aliases because typeof [] === "object" passes validation', () => { - // session-aliases.js line 58: `typeof data.aliases !== 'object'` is the guard. - // Arrays are typeof 'object' in JavaScript, so {"aliases": [1,2,3]} passes - // validation. The returned data.aliases is an array, not a plain object. - // Downstream code (Object.keys, Object.entries, bracket access) behaves - // differently on arrays vs objects but doesn't crash — it just produces - // unexpected results like numeric string keys "0", "1", "2". + console.log('\nArray alias maps:'); + if (test('recovers an invalid array map without losing newly saved aliases', () => { resetAliases(); const aliasesPath = aliases.getAliasesPath(); fs.writeFileSync(aliasesPath, JSON.stringify({ @@ -1321,15 +1320,11 @@ function runTests() { metadata: { totalCount: 3, lastUpdated: new Date().toISOString() } })); const data = aliases.loadAliases(); - // The array passes the typeof 'object' check and is returned as-is - assert.ok(Array.isArray(data.aliases), - 'data.aliases should be an array (typeof [] === "object" bypasses guard)'); - assert.strictEqual(data.aliases.length, 3, - 'Array should have 3 elements'); - // Object.keys on an array returns ["0", "1", "2"] — numeric index strings - const keys = Object.keys(data.aliases); - assert.deepStrictEqual(keys, ['0', '1', '2'], - 'Object.keys of array returns numeric string indices, not named alias keys'); + assert.deepStrictEqual(data.aliases, {}); + assert.deepStrictEqual(aliases.listAliases(), []); + assert.strictEqual(aliases.setAlias('recovered', '/session/recovered').success, true); + assert.strictEqual(aliases.resolveAlias('recovered').sessionPath, '/session/recovered'); + assert.strictEqual(aliases.loadAliases().metadata.totalCount, 1); })) passed++; else failed++; // ── Round 104: resolveSessionAlias with path-traversal input (passthrough without validation) ── diff --git a/tests/lib/skill-dashboard.test.js b/tests/lib/skill-dashboard.test.js index d1f29d0df..342606ec1 100644 --- a/tests/lib/skill-dashboard.test.js +++ b/tests/lib/skill-dashboard.test.js @@ -11,6 +11,7 @@ const path = require('path'); const { spawnSync } = require('child_process'); const dashboard = require('../../scripts/lib/skill-evolution/dashboard'); +const health = require('../../scripts/lib/skill-evolution/health'); const versioning = require('../../scripts/lib/skill-evolution/versioning'); const _provenance = require('../../scripts/lib/skill-evolution/provenance'); @@ -148,6 +149,24 @@ function runTests() { } })) passed++; else failed++; + if (test('bucketByDay includes the window cutoff without duplicating internal boundaries', () => { + const nowMs = Date.parse(now); + const records = [ + { outcome: 'failure', recorded_at: '2026-03-12T12:00:00.000Z' }, + { outcome: 'success', recorded_at: '2026-03-13T12:00:00.000Z' }, + { outcome: 'success', recorded_at: '2026-03-14T12:00:00.000Z' }, + { outcome: 'success', recorded_at: now }, + { outcome: 'failure', recorded_at: '2026-03-12T11:59:59.999Z' }, + { outcome: 'failure', recorded_at: '2026-03-15T12:00:00.001Z' }, + ]; + const buckets = dashboard.bucketByDay(records, nowMs, 3); + const windowRecords = health.filterRecordsWithinDays(records, nowMs, 3); + assert.strictEqual(windowRecords.length, 4); + assert.deepStrictEqual(buckets.map(bucket => bucket.runs), [2, 1, 1]); + assert.strictEqual(buckets.reduce((total, bucket) => total + bucket.runs, 0), windowRecords.length); + assert.strictEqual(buckets[0].rate, 0.5); + })) passed++; else failed++; + console.log('\nPanel renderers:'); const alphaSkillDir = createSkill(skillsRoot, 'alpha', '# Alpha\n'); diff --git a/tests/lib/skill-evolution.test.js b/tests/lib/skill-evolution.test.js index 53ed517b2..fa62ba942 100644 --- a/tests/lib/skill-evolution.test.js +++ b/tests/lib/skill-evolution.test.js @@ -445,6 +445,12 @@ function runTests() { [{ ...valid, recorded_at: 'not-a-date' }, /recorded_at must be/], [{ ...valid, tokens_used: 'lots' }, /tokens_used must be a number/], [{ ...valid, duration_ms: Number.POSITIVE_INFINITY }, /duration_ms must be a number/], + ...[-1, '-1'].flatMap(value => [ + [{ ...valid, tokens_used: value }, /tokens_used must be a number/], + [{ ...valid, tokensUsed: value }, /tokens_used must be a number/], + [{ ...valid, duration_ms: value }, /duration_ms must be a number/], + [{ ...valid, durationMs: value }, /duration_ms must be a number/], + ]), ...[42, 0, false, {}, []].flatMap(value => [ [{ ...valid, failure_reason: value }, /failure_reason must be a string or null/], [{ ...valid, failureReason: value }, /failure_reason must be a string or null/], @@ -480,6 +486,13 @@ function runTests() { /failure_reason must be a string or null/ ); assert.ok(!fs.existsSync(invalidRunsFile), 'invalid reasons must be rejected before writing a run'); + for (const invalidFields of [{ tokens_used: -1 }, { duration_ms: -1 }]) { + assert.throws(() => tracker.recordSkillExecution({ ...valid, ...invalidFields }, { runsFilePath: invalidRunsFile })); + assert.ok(!fs.existsSync(invalidRunsFile), 'invalid telemetry must be rejected before writing a run'); + } + const zeroStrings = tracker.normalizeExecutionRecord({ ...valid, tokens_used: '0', duration_ms: '0' }); + assert.strictEqual(zeroStrings.tokens_used, 0); + assert.strictEqual(zeroStrings.duration_ms, 0); })) passed++; else failed++; if (test('uses state-store adapters for recording and reading when available', () => { diff --git a/tests/scripts/build-opencode.test.js b/tests/scripts/build-opencode.test.js index 66359d4cc..36c924c1c 100644 --- a/tests/scripts/build-opencode.test.js +++ b/tests/scripts/build-opencode.test.js @@ -4,6 +4,7 @@ const assert = require("assert") const fs = require("fs") +const { builtinModules } = require("module") const os = require("os") const path = require("path") const { spawnSync } = require("child_process") @@ -116,6 +117,36 @@ function main() { assert.strictEqual(result.status, 0, result.stderr) assert.ok(fs.existsSync(distEntry), ".opencode/dist/index.js should exist after build") }], + ["built OpenCode bundle only imports packages declared as runtime dependencies", () => { + // OpenCode installs npm plugins without devDependencies, so any package + // the compiled bundle imports at runtime must ship as a dependency. + const runtimeDeps = new Set([ + ...Object.keys(packageJson.dependencies || {}), + ...Object.keys(packageJson.peerDependencies || {}), + ]) + const distDir = path.dirname(distEntry) + const imported = new Set() + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + walk(entryPath) + } else if (entry.name.endsWith(".js")) { + const source = fs.readFileSync(entryPath, "utf8") + for (const match of source.matchAll(/(?:\bfrom|\bimport)\s*\(?\s*["']([^"'.][^"']*)["']/g)) { + const specifier = match[1] + if (specifier.startsWith("node:") || builtinModules.includes(specifier)) continue + const parts = specifier.split("/") + imported.add(specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]) + } + } + } + } + walk(distDir) + assert.ok(imported.size > 0, "expected the OpenCode bundle to import at least one package") + const undeclared = [...imported].filter((name) => !runtimeDeps.has(name)).sort() + assert.deepStrictEqual(undeclared, [], `undeclared runtime imports: ${undeclared.join(", ")}`) + }], ["package.json declares a resolvable OpenCode plugin entry", () => { assert.strictEqual(packageJson.main, ".opencode/dist/index.js") assert.ok(packageJson.exports, "package.json must declare an exports map") diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 36db330b8..84f1569e8 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -1692,6 +1692,51 @@ function runTests() { cleanup(projectDir); } })) passed++; else failed++; + if (test('legacy install respects CLAUDE_CONFIG_DIR environment variable', () => { + const homeDir = createTempDir('install-apply-legacy-claude-config-'); + const projectDir = createTempDir('install-apply-legacy-claude-config-project-'); + const customClaudeDir = path.join(homeDir, 'custom-claude-config'); + + try { + const env = { CLAUDE_CONFIG_DIR: customClaudeDir }; + // Use legacy mode (--target claude without --profile), provide a language + const result = run(['--target', 'claude', 'typescript', '--no-hooks'], { cwd: projectDir, homeDir, env }); + assert.strictEqual(result.code, 0, result.stderr); + + // Verify install landed in CLAUDE_CONFIG_DIR, not ~/.claude + assert.ok(fs.existsSync(path.join(customClaudeDir, 'rules', 'ecc', 'common', 'coding-style.md')), + 'Expected rules in CLAUDE_CONFIG_DIR'); + assert.ok(fs.existsSync(path.join(customClaudeDir, 'ecc', 'install-state.json')), + 'Expected install-state in CLAUDE_CONFIG_DIR'); + assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'rules', 'ecc')), + 'Should not install to default ~/.claude when CLAUDE_CONFIG_DIR is set'); + + const state = readJson(path.join(customClaudeDir, 'ecc', 'install-state.json')); + assert.strictEqual(fs.realpathSync(state.target.root), fs.realpathSync(customClaudeDir)); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + + if (test('legacy install passes CLAUDE_CONFIG_DIR through to adapter', () => { + const homeDir = createTempDir('install-apply-legacy-env-'); + const projectDir = createTempDir('install-apply-legacy-env-project-'); + const customClaudeDir = path.join(homeDir, 'custom-claude-config-env'); + + try { + const env = { CLAUDE_CONFIG_DIR: customClaudeDir }; + const result = run(['--target', 'claude', 'typescript', '--no-hooks'], { cwd: projectDir, homeDir, env }); + assert.strictEqual(result.code, 0, result.stderr); + + // Verify install landed in CLAUDE_CONFIG_DIR + const state = readJson(path.join(customClaudeDir, 'ecc', 'install-state.json')); + assert.strictEqual(fs.realpathSync(state.target.root), fs.realpathSync(customClaudeDir)); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/scripts/session-inspect.test.js b/tests/scripts/session-inspect.test.js index 21b3ac1df..0dd96aa74 100644 --- a/tests/scripts/session-inspect.test.js +++ b/tests/scripts/session-inspect.test.js @@ -9,6 +9,7 @@ const path = require('path'); const { execFileSync } = require('child_process'); const { getFallbackSessionRecordingPath } = require('../../scripts/lib/session-adapters/canonical-session'); +const { parseArgs } = require('../../scripts/session-inspect'); const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'session-inspect.js'); @@ -64,12 +65,29 @@ function runTests() { let passed = 0; let failed = 0; + if (test('does not mistake option values for the positional target', () => { + for (const flag of ['--adapter', '--target-type', '--skill', '--amendment-id', '--observations', '--write']) { + for (const args of [[flag, 'value', 'claude:latest'], ['claude:latest', flag, 'value']]) { + assert.strictEqual(parseArgs(['node', SCRIPT, ...args]).target, 'claude:latest'); + } + } + assert.strictEqual(parseArgs(['node', SCRIPT, '--adapter', 'claude-history']).target, undefined); + })) passed++; else failed++; + if (test('shows usage when no target is provided', () => { const result = run(); assert.strictEqual(result.code, 1); assert.ok(result.stdout.includes('Usage:')); })) passed++; else failed++; + if (test('rejects missing values for every value-taking option', () => { + for (const flag of ['--adapter', '--target-type', '--skill', '--amendment-id', '--observations', '--write']) { + for (const args of [[flag], [flag, '--write', 'out.json', 'claude:latest'], [flag, 'value', flag]]) { + assert.throws(() => parseArgs(['node', SCRIPT, ...args]), { message: `Missing value for ${flag}` }); + } + } + })) passed++; else failed++; + if (test('lists registered adapters', () => { const result = run(['--list-adapters']); assert.strictEqual(result.code, 0, result.stderr); @@ -152,14 +170,25 @@ function runTests() { '# Inspect Session\n\n**Branch:** feat/session-inspect\n' ); - const result = run(['claude:latest', '--write', outputPath], { - env: { HOME: homeDir } - }); - - assert.strictEqual(result.code, 0, result.stderr); - assert.ok(fs.existsSync(outputPath)); - const written = JSON.parse(fs.readFileSync(outputPath, 'utf8')); - assert.strictEqual(written.adapterId, 'claude-history'); + for (const args of [ + ['claude:latest', '--adapter', 'claude-history', '--write', outputPath], + ['--adapter', 'claude-history', '--write', outputPath, 'claude:latest'], + ['--write', outputPath, 'claude:latest', '--adapter', 'claude-history'], + ]) { + fs.rmSync(outputPath, { force: true }); + const result = run(args, { + env: { HOME: homeDir, ECC_SESSION_RECORDING_DIR: outputDir } + }); + assert.strictEqual(result.code, 0, result.stderr); + assert.ok(fs.existsSync(outputPath)); + const written = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + assert.strictEqual(written.adapterId, 'claude-history'); + } + fs.rmSync(outputPath, { force: true }); + const invalid = run(['--adapter', '--write', outputPath, 'claude:latest'], { env: { HOME: homeDir } }); + assert.strictEqual(invalid.code, 1); + assert.match(invalid.stderr, /Missing value for --adapter/); + assert.ok(!fs.existsSync(outputPath)); } finally { fs.rmSync(homeDir, { recursive: true, force: true }); fs.rmSync(outputDir, { recursive: true, force: true }); diff --git a/tests/scripts/sessions-cli.test.js b/tests/scripts/sessions-cli.test.js new file mode 100644 index 000000000..50e4708c2 --- /dev/null +++ b/tests/scripts/sessions-cli.test.js @@ -0,0 +1,97 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); +const { parseArgs } = require('../../scripts/sessions-cli'); +const { createStateStore } = require('../../scripts/lib/state-store'); + +const SCRIPT = path.resolve(__dirname, '../../scripts/sessions-cli.js'); + +async function withTempHome(fn) { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-sessions-cli-')); + try { + await fn(homeDir); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } +} + +function runCli(homeDir, args) { + return spawnSync(process.execPath, [SCRIPT, ...args], { + cwd: homeDir, + encoding: 'utf8', + env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir }, + }); +} + +test('requires a value for every db and limit option', () => { + for (const flag of ['--db', '--limit']) { + for (const args of [[flag], [flag, '--json'], [flag, '-h'], [flag, 'value', flag]]) { + assert.throws(() => parseArgs(['node', SCRIPT, ...args]), { message: `Missing value for ${flag}` }); + } + } + const parsed = parseArgs(['node', SCRIPT, '--db', 'state.db', '--limit', '2', '--json', 'demo']); + assert.equal(parsed.dbPath, 'state.db'); + assert.equal(parsed.limit, '2'); + assert.equal(parsed.json, true); + assert.equal(parsed.sessionId, 'demo'); +}); + +test('rejects incomplete options without creating a database', async () => { + await withTempHome(async homeDir => { + for (const flag of ['--db', '--limit']) { + for (const args of [[flag], [flag, '--json'], [flag, '-h']]) { + const result = runCli(homeDir, args); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, new RegExp(`Missing value for ${flag}`)); + assert.deepEqual(fs.readdirSync(homeDir), []); + } + } + assert.match(runCli(homeDir, ['--help']).stdout, /Usage:/); + assert.deepEqual(fs.readdirSync(homeDir), []); + }); +}); + +test('retains valid database and limit options for listing and inspecting sessions', async () => { + await withTempHome(async homeDir => { + const dbPath = path.join(homeDir, 'state.db'); + const empty = runCli(homeDir, ['--db', dbPath]); + assert.equal(empty.status, 0, empty.stderr); + assert.match(empty.stdout, /No sessions found/); + const store = await createStateStore({ dbPath }); + try { + store.upsertSession({ + id: 'demo', adapterId: 'claude-history', harness: 'claude', state: 'recorded', repoRoot: homeDir, + startedAt: '2026-03-15T08:00:00Z', endedAt: '2026-03-15T09:00:00Z', + snapshot: { workers: [{ id: 'worker', state: 'active', branch: 'main', worktree: homeDir }, {}] }, + }); + store.upsertSession({ id: 'empty', adapterId: 'claude-history', harness: 'claude', state: 'recorded' }); + store.insertSkillRun({ + id: 'run', skillId: 'demo-skill', skillVersion: 'v1', sessionId: 'demo', taskDescription: 'A task', outcome: 'success', + }); + store.insertDecision({ id: 'choice', sessionId: 'demo', title: 'A choice', rationale: 'A reason', status: 'active' }); + } finally { + store.close(); + } + const list = runCli(homeDir, ['--db', dbPath, '--limit', '1', '--json']); + assert.equal(list.status, 0, list.stderr); + assert.equal(JSON.parse(list.stdout).sessions.length, 1); + const human = runCli(homeDir, ['--db', dbPath]); + assert.equal(human.status, 0, human.stderr); + assert.match(human.stdout, /Total sessions: 2/); + const detail = runCli(homeDir, ['demo', '--db', dbPath]); + assert.equal(detail.status, 0, detail.stderr); + assert.match(detail.stdout, /Workers: 2/); + assert.match(detail.stdout, /Task: A task/); + assert.match(detail.stdout, /Title: A choice/); + const emptyDetail = runCli(homeDir, ['empty', '--db', dbPath, '--json']); + assert.equal(emptyDetail.status, 0, emptyDetail.stderr); + assert.deepEqual(JSON.parse(emptyDetail.stdout).workers, []); + assert.match(runCli(homeDir, ['empty', '--db', dbPath]).stdout, /Workers: 0/); + assert.match(runCli(homeDir, ['missing', '--db', dbPath]).stderr, /Session not found: missing/); + }); +}); diff --git a/tests/skills/ck-paths.test.js b/tests/skills/ck-paths.test.js new file mode 100644 index 000000000..c85c5d40b --- /dev/null +++ b/tests/skills/ck-paths.test.js @@ -0,0 +1,143 @@ +#!/usr/bin/env node +/** + * Tests for ck (Context Keeper) path handling: skill-relative script paths, + * heredoc JSON input, and Claude Code project directory encoding. + * + * Run with: node tests/skills/ck-paths.test.js + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { pathToFileURL } = require('url'); +const { spawnSync } = require('child_process'); + +const SKILL_DIR = path.join(__dirname, '..', '..', 'skills', 'ck'); +const SKILL_MD = path.join(SKILL_DIR, 'SKILL.md'); +const SESSION_START = path.join(SKILL_DIR, 'hooks', 'session-start.mjs'); + +async function test(name, fn) { + try { + await fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (err) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function tempEnv() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-paths-')); + const home = path.join(root, 'home'); + const project = path.join(root, "Bob's App v2"); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(project, { recursive: true }); + const env = { ...process.env, HOME: home, USERPROFILE: home, PWD: project }; + return { root, home, project, env }; +} + +// Return the body of the first ```bash fence that contains `marker`. +function bashBlock(marker) { + const md = fs.readFileSync(SKILL_MD, 'utf8'); + const block = [...md.matchAll(/```bash\n([\s\S]*?)```/g)].map(m => m[1]).find(b => b.includes(marker)); + assert.ok(block, `no bash block containing ${marker}`); + return block; +} + +function runDocumentedSave(marker, placeholder, payload, ctx) { + const script = bashBlock(marker) + .replaceAll('${CLAUDE_SKILL_DIR}', SKILL_DIR) + .replace(placeholder, () => JSON.stringify(payload)); + return spawnSync('bash', ['-c', script], { cwd: ctx.project, env: ctx.env, encoding: 'utf8' }); +} + +async function runTests() { + console.log('\n=== Testing ck path handling ===\n'); + + let passed = 0; + let failed = 0; + + if (await test('encodeProjectPath maps every non-alphanumeric character to "-"', async () => { + const { encodeProjectPath } = await import(pathToFileURL(path.join(SKILL_DIR, 'commands', 'shared.mjs')).href); + assert.strictEqual(encodeProjectPath('/home/u/My Project_v1.2'), '-home-u-My-Project-v1-2'); + assert.strictEqual(encodeProjectPath('C:\\Users\\u\\app'), 'C--Users-u-app'); + })) passed++; else failed++; + + if (await test('SessionStart hook injects the SKILL.md next to it, with the skill dir filled in', async () => { + const ctx = tempEnv(); + try { + const result = spawnSync(process.execPath, [SESSION_START], { + cwd: ctx.project, env: ctx.env, input: '{}', encoding: 'utf8', + }); + assert.strictEqual(result.status, 0, result.stderr); + const { additionalContext } = JSON.parse(result.stdout); + assert.ok(additionalContext.includes('# ck — Context Keeper'), 'SKILL.md was not injected'); + assert.ok(!additionalContext.includes('${CLAUDE_SKILL_DIR}'), 'skill dir placeholder left unexpanded'); + // Escaped for double-quoted bash (Windows backslashes are doubled). + const shellCommandsDir = `${SKILL_DIR.replace(/[\\$`"]/g, '\\$&')}/commands`; + assert.ok(additionalContext.includes(shellCommandsDir), 'skill dir not substituted'); + } finally { + fs.rmSync(ctx.root, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (process.platform === 'win32') { + console.log(' - skipped shell-special skill dir test on Windows'); + } else if (await test('hook-injected commands run from a skill dir with shell-special characters', async () => { + const ctx = tempEnv(); + try { + const skillDir = path.join(ctx.root, 'skills $USER $& `x` "q"', 'ck'); + fs.cpSync(SKILL_DIR, skillDir, { recursive: true }); + const result = spawnSync(process.execPath, [path.join(skillDir, 'hooks', 'session-start.mjs')], { + cwd: ctx.project, env: ctx.env, input: '{}', encoding: 'utf8', + }); + assert.strictEqual(result.status, 0, result.stderr); + const { additionalContext } = JSON.parse(result.stdout); + const listCommand = additionalContext.split('\n').find(line => line.includes('commands/list.mjs')); + assert.ok(listCommand, 'no list.mjs command injected'); + const list = spawnSync('bash', ['-c', listCommand], { cwd: ctx.project, env: ctx.env, encoding: 'utf8' }); + // No projects are registered, so list.mjs reaching its own message proves the path resolved. + assert.ok(list.stdout.includes('No projects registered'), `${listCommand}\n${list.stdout}${list.stderr}`); + } finally { + fs.rmSync(ctx.root, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (process.platform === 'win32') { + console.log(' - skipped documented bash heredoc test on Windows'); + } else if (await test('documented heredoc commands save JSON with apostrophes into the native memory dir', async () => { + const ctx = tempEnv(); + try { + const init = runDocumentedSave('save.mjs" --init', '', { + name: 'bobs-app', path: ctx.project, description: "Bob's app, don't break it", + stack: ['node'], goal: "Ship Bob's release", constraints: [], + }, ctx); + assert.strictEqual(init.status, 0, init.stdout + init.stderr); + + const save = runDocumentedSave('save.mjs" <<', '', { + summary: "Fixed the user's login", leftOff: "didn't finish tests", nextSteps: ["write O'Brien's test"], + decisions: [{ what: "use Bob's parser", why: "it's faster" }], blockers: [], + }, ctx); + assert.strictEqual(save.status, 0, save.stdout + save.stderr); + + const encoded = ctx.project.replace(/[^A-Za-z0-9]/g, '-'); + const memDir = path.join(ctx.home, '.claude', 'projects', encoded, 'memory'); + const files = fs.existsSync(memDir) ? fs.readdirSync(memDir) : []; + assert.strictEqual(files.length, 1, `expected one memory entry in ${memDir}`); + assert.ok(fs.readFileSync(path.join(memDir, files[0]), 'utf8').includes("Fixed the user's login")); + } finally { + fs.rmSync(ctx.root, { recursive: true, force: true }); + } + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exitCode = failed > 0 ? 1 : 0; +} + +runTests(); diff --git a/tests/skills/delivery-gate-quality-gate.test.js b/tests/skills/delivery-gate-quality-gate.test.js new file mode 100644 index 000000000..106881775 --- /dev/null +++ b/tests/skills/delivery-gate-quality-gate.test.js @@ -0,0 +1,133 @@ +/** + * Subprocess tests for skills/delivery-gate/hooks/quality-gate.py. + * + * Run with: node tests/skills/delivery-gate-quality-gate.test.js + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const SCRIPT = path.join(__dirname, '..', '..', 'skills', 'delivery-gate', 'hooks', 'quality-gate.py'); + +function findPython() { + const candidates = [ + { command: process.env.PYTHON, args: [] }, + { command: 'python3', args: [] }, + { command: 'python', args: [] }, + { command: 'py', args: ['-3'] }, + ].filter(candidate => candidate.command); + + // Probe for Python 3 itself: a Python 2 `--version` also exits 0, but the + // driver below needs importlib.util. + for (const candidate of candidates) { + const result = spawnSync(candidate.command, [...candidate.args, '-c', 'import sys; sys.exit(sys.version_info[0] != 3)'], { + encoding: 'utf8', + timeout: 5000, + }); + if (result.status === 0) { + return candidate; + } + } + return null; +} + +const PYTHON = findPython(); + +// Load the hyphenated hook file as a module and pin the disk check (free GB in +// argv[2]), so the result does not depend on the test machine's free space. +const DRIVER = [ + 'import importlib.util, sys', + 'spec = importlib.util.spec_from_file_location("quality_gate", sys.argv[1])', + 'm = importlib.util.module_from_spec(spec)', + 'spec.loader.exec_module(m)', + 'm.check_disk = lambda: int(sys.argv[2])', + 'm.main()', +].join('\n'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function setup() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'delivery-gate-')); + const home = path.join(root, 'home'); + const project = path.join(root, 'My Project_v1.2'); + fs.mkdirSync(project, { recursive: true }); + + // Claude Code maps every non-alphanumeric character to '-'. + const encoded = project.replace(/[^A-Za-z0-9]/g, '-'); + const memDir = path.join(home, '.claude', 'projects', encoded, 'memory'); + fs.mkdirSync(memDir, { recursive: true }); // present, but no learning libs + + const transcript = path.join(root, 'transcript.jsonl'); + fs.writeFileSync(transcript, [1, 2, 3] + .map(i => JSON.stringify({ type: 'tool_use', name: 'Edit', input: { file_path: `f${i}.js` } })) + .join('\n'), 'utf8'); + + return { root, home, project, memDir, transcript }; +} + +function runHook(ctx, payload, diskFreeGb = 100) { + return spawnSync(PYTHON.command, [...PYTHON.args, '-c', DRIVER, SCRIPT, String(diskFreeGb)], { + input: JSON.stringify({ transcript_path: ctx.transcript, ...payload }), + encoding: 'utf8', + timeout: 30000, + env: { ...process.env, HOME: ctx.home, USERPROFILE: ctx.home, CLAUDE_PROJECT_DIR: ctx.project }, + }); +} + +function runTests() { + console.log('\n=== Testing delivery-gate quality-gate.py ===\n'); + + if (!PYTHON) { + console.log(' - skipped: python not found'); + console.log('\nPassed: 0'); + console.log('Failed: 0'); + return; + } + + let passed = 0; + let failed = 0; + const ctx = setup(); + + try { + if (test('finds the memory dir of a project path with spaces and punctuation', () => { + const result = runHook(ctx, {}); + assert.strictEqual(result.status, 2, `expected block, got ${result.status}: ${result.stderr}`); + assert.match(result.stderr, /learning libs stale/); + assert.ok(!result.stderr.includes('No project memory directory found'), result.stderr); + })) passed++; else failed++; + + if (test('blocks on critical disk space when stop_hook_active is absent', () => { + const result = runHook(ctx, {}, 1); + assert.strictEqual(result.status, 2, `expected block, got ${result.status}: ${result.stderr}`); + assert.match(result.stderr, /Blocked: disk space/); + })) passed++; else failed++; + + if (test('does not block again when stop_hook_active is true', () => { + const result = runHook(ctx, { stop_hook_active: true }, 1); + assert.strictEqual(result.status, 0, `expected exit 0, got ${result.status}: ${result.stderr}`); + })) passed++; else failed++; + } finally { + fs.rmSync(ctx.root, { recursive: true, force: true }); + } + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exitCode = failed > 0 ? 1 : 0; +} + +runTests(); diff --git a/tests/skills/frontend-slides-export-pdf.test.js b/tests/skills/frontend-slides-export-pdf.test.js new file mode 100644 index 000000000..5ce0bb7b4 --- /dev/null +++ b/tests/skills/frontend-slides-export-pdf.test.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/** + * Stubbed run of skills/frontend-slides/scripts/export-pdf.sh: npm, npx and + * node are replaced by stubs, so no network or browser is needed. + * + * Run with: node tests/skills/frontend-slides-export-pdf.test.js + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const SCRIPT = path.join(__dirname, '..', '..', 'skills', 'frontend-slides', 'scripts', 'export-pdf.sh'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function withStubbedExport(htmlRelPath, fn) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'slides-export-')); + try { + const bin = path.join(root, 'bin'); + const work = path.join(root, 'work'); + fs.mkdirSync(bin); + fs.mkdirSync(path.dirname(path.join(work, htmlRelPath)), { recursive: true }); + // npm/npx succeed without doing anything; open/xdg-open must not launch a viewer. + for (const name of ['npm', 'npx', 'open', 'xdg-open']) { + fs.writeFileSync(path.join(bin, name), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + } + // node stub checks the served HTML exists, then writes the PDF to the output path argument. + fs.writeFileSync(path.join(bin, 'node'), '#!/bin/sh\n[ -f "$2/$3" ] || exit 3\nprintf "%%PDF-stub" > "$4"\n', { mode: 0o755 }); + fs.writeFileSync(path.join(work, htmlRelPath), '\n'); + const run = (args, extraEnv = {}) => spawnSync('bash', [SCRIPT, ...args], { + cwd: work, + encoding: 'utf8', + env: { ...process.env, ...extraEnv, PATH: `${bin}${path.delimiter}${process.env.PATH}` }, + }); + fn({ work, run }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function runTests() { + console.log('\n=== Testing frontend-slides export-pdf.sh ===\n'); + + if (process.platform === 'win32') { + console.log(' - skipped: bash script test on Windows'); + console.log('\nPassed: 0'); + console.log('Failed: 0'); + return; + } + + let passed = 0; + let failed = 0; + + if (test('writes a relative output path next to the caller, not into the deleted temp dir', () => { + withStubbedExport('presentation.html', ({ work, run }) => { + const result = run(['./presentation.html', './slides.pdf']); + assert.strictEqual(result.status, 0, result.stdout + result.stderr); + assert.ok(fs.existsSync(path.join(work, 'slides.pdf')), 'slides.pdf was not written to the working directory'); + }); + })) passed++; else failed++; + + if (test('resolves nested relative input and output paths when CDPATH is exported', () => { + withStubbedExport(path.join('decks', 'presentation.html'), ({ work, run }) => { + const result = run(['decks/presentation.html', 'exports/slides.pdf'], { CDPATH: '.' }); + assert.strictEqual(result.status, 0, result.stdout + result.stderr); + assert.ok(fs.existsSync(path.join(work, 'exports', 'slides.pdf')), 'exports/slides.pdf was not written'); + }); + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exitCode = failed > 0 ? 1 : 0; +} + +runTests(); diff --git a/tests/skills/opensource-forker-history.test.js b/tests/skills/opensource-forker-history.test.js new file mode 100644 index 000000000..b8ac8a984 --- /dev/null +++ b/tests/skills/opensource-forker-history.test.js @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * Runs the documented Step 6 of agents/opensource-forker.md, then the opensource-pipeline fold-in + * block, in a throwaway dir that already holds FORK_REPORT.md / SANITIZATION_REPORT.md (as if the + * source project shipped them, with a .gitignore that re-includes them), and checks they stay out + * of the single published commit. + * + * Run with: node tests/skills/opensource-forker-history.test.js + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const ROOT = path.join(__dirname, '..', '..'); +const NAME = 'pre-existing reports stay out of the published fork commit'; + +function bashBlock(file, after) { + const section = fs.readFileSync(path.join(ROOT, file), 'utf8').split(after)[1]; + assert.ok(section, `${file} is missing "${after}"`); + const block = section.match(/```bash\n([\s\S]*?)```/); + assert.ok(block, `no bash block after "${after}" in ${file}`); + return block[1]; +} + +function run(script, dir) { + const r = spawnSync('bash', ['-ec', script], { + cwd: dir, + encoding: 'utf8', + env: { + ...process.env, + GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@example.com', + GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@example.com', + }, + }); + assert.strictEqual(r.status, 0, r.stderr); + return r.stdout; +} + +if (process.platform === 'win32') { + // The documented blocks are bash; a Windows temp path does not survive `cd`. + console.log(` - skipped: ${NAME} (bash test on Windows)`); + console.log('\nPassed: 0'); + console.log('Failed: 0'); + process.exit(0); +} + +const step6 = bashBlock('agents/opensource-forker.md', '### Step 6: Clean Git History'); +const foldIn = bashBlock('skills/opensource-pipeline/SKILL.md', 'Fold changes in with:'); +const publishBlock = bashBlock('skills/opensource-pipeline/SKILL.md', '#### Step 7: GitHub Publish'); + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oss-forker-')); +let failed = 0; +try { + for (const f of ['README.md', 'FORK_REPORT.md', 'SANITIZATION_REPORT.md']) { + fs.writeFileSync(path.join(dir, f), 'x\n'); + } + // A .gitignore rule outranks .git/info/exclude, so a negation would re-include the reports. + fs.writeFileSync(path.join(dir, '.gitignore'), '!FORK_REPORT.md\n!SANITIZATION_REPORT.md\n'); + + run(step6.replaceAll('TARGET_DIR', dir), dir); + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), 'packaged\n'); + const out = run(foldIn.replaceAll('{STAGING_PATH}', dir), dir).trim().split('\n'); + assert.strictEqual(out.at(-1), '1', `fold-in must keep one commit, got: ${out.at(-1)}`); + + const files = spawnSync('git', ['ls-tree', '-r', '--name-only', 'HEAD'], { cwd: dir, encoding: 'utf8' }).stdout.trim(); + assert.strictEqual(files, '.gitignore\nCLAUDE.md\nREADME.md', `commit must not hold the reports, got: ${files}`); + const scanned = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).stdout.trim(); + const bin = path.join(dir, '.git', 'test-bin'); + const published = path.join(dir, '.git', 'published-marker'); + fs.mkdirSync(bin); + fs.writeFileSync(path.join(bin, 'gh'), `#!/bin/sh\ntouch "${published}"\n`, { mode: 0o755 }); + const publish = publishBlock + .replaceAll('{STAGING_PATH}', dir) + .replaceAll('{SCANNED_COMMIT}', scanned); + const env = { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}` }; + const clean = spawnSync('bash', ['-ec', publish], { cwd: dir, encoding: 'utf8', env }); + assert.strictEqual(clean.status, 0, clean.stderr); + assert.ok(fs.existsSync(published), 'clean scanned commit with only untracked reports should publish'); + fs.rmSync(published); + fs.writeFileSync(path.join(dir, 'extra.txt'), 'unreviewed\n'); + const dirty = spawnSync('bash', ['-ec', publish], { cwd: dir, encoding: 'utf8', env }); + assert.notStrictEqual(dirty.status, 0, 'other untracked files must block publication'); + assert.ok(!fs.existsSync(published), 'dirty tree must not invoke gh'); + console.log(` ✓ ${NAME}`); +} catch (err) { + failed = 1; + console.log(` ✗ ${NAME}`); + console.log(` Error: ${err.message}`); +} finally { + fs.rmSync(dir, { recursive: true, force: true }); +} + +console.log(`\nPassed: ${1 - failed}`); +console.log(`Failed: ${failed}`); +process.exitCode = failed; diff --git a/tests/skills/opensource-pipeline-publish-guard.test.js b/tests/skills/opensource-pipeline-publish-guard.test.js new file mode 100644 index 000000000..7c96411d1 --- /dev/null +++ b/tests/skills/opensource-pipeline-publish-guard.test.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * Runs the documented Step 7 publish guard of skills/opensource-pipeline/SKILL.md + * against a throwaway git repo, with `gh` stubbed so nothing is published. + * + * Run with: node tests/skills/opensource-pipeline-publish-guard.test.js + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const SKILL_MD = path.join(__dirname, '..', '..', 'skills', 'opensource-pipeline', 'SKILL.md'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function publishBlock() { + const md = fs.readFileSync(SKILL_MD, 'utf8'); + const step = md.split('#### Step 7: GitHub Publish')[1]; + assert.ok(step, 'SKILL.md is missing "#### Step 7: GitHub Publish"'); + const m = step.match(/```bash\n([\s\S]*?)```/); + assert.ok(m, 'no bash block in Step 7'); + return m[1]; +} + +function git(cwd, ...args) { + const r = spawnSync('git', args, { cwd, encoding: 'utf8' }); + assert.strictEqual(r.status, 0, r.stderr); + return r.stdout.trim(); +} + +// One-commit staging repo; returns the commit the sanitizer "scanned". +function withStaging(fn, { committedReport = false } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'oss-publish-')); + try { + const staging = path.join(root, 'staging'); + const bin = path.join(root, 'bin'); + fs.mkdirSync(staging); + fs.mkdirSync(bin); + fs.writeFileSync(path.join(bin, 'gh'), `#!/bin/sh\ntouch "${path.join(root, 'published')}"\n`, { mode: 0o755 }); + git(staging, 'init', '-q'); + git(staging, 'config', 'user.email', 'test@example.com'); + git(staging, 'config', 'user.name', 'test'); + fs.writeFileSync(path.join(staging, 'README.md'), 'clean\n'); + if (committedReport) fs.writeFileSync(path.join(staging, 'FORK_REPORT.md'), 'must not publish\n'); + git(staging, 'add', '-A'); + git(staging, 'commit', '-q', '-m', 'init'); + const scanned = git(staging, 'rev-parse', 'HEAD'); + const publish = () => { + const script = publishBlock() + .replaceAll('{STAGING_PATH}', staging) + .replaceAll('{SCANNED_COMMIT}', scanned); + const r = spawnSync('bash', ['-c', script], { + encoding: 'utf8', + env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}` }, + }); + return { status: r.status, published: fs.existsSync(path.join(root, 'published')) }; + }; + fn({ staging, publish }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function runTests() { + console.log('\n=== Testing opensource-pipeline publish guard ===\n'); + + if (process.platform === 'win32') { + console.log(' - skipped: bash guard test on Windows'); + console.log('\nPassed: 0'); + console.log('Failed: 0'); + return; + } + + let passed = 0; + let failed = 0; + + if (test('publishes the commit the sanitizer scanned', () => { + withStaging(({ publish }) => { + const r = publish(); + assert.strictEqual(r.status, 0); + assert.ok(r.published, 'gh repo create was not run'); + }); + })) passed++; else failed++; + + if (test('refuses to publish a commit amended after the scan', () => { + withStaging(({ staging, publish }) => { + fs.writeFileSync(path.join(staging, 'README.md'), 'edited during review: me@example.com\n'); + git(staging, 'commit', '-q', '-a', '--amend', '--no-edit'); + const r = publish(); + assert.notStrictEqual(r.status, 0); + assert.ok(!r.published, 'gh repo create ran for an unscanned commit'); + }); + })) passed++; else failed++; + + if (test('refuses to publish a commit containing a report', () => { + withStaging(({ publish }) => { + const r = publish(); + assert.notStrictEqual(r.status, 0); + assert.ok(!r.published, 'gh repo create ran with a tracked report'); + }, { committedReport: true }); + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exitCode = failed > 0 ? 1 : 0; +} + +runTests(); diff --git a/yarn.lock b/yarn.lock index 032184e3b..a8533cbcd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,15 +5,6 @@ __metadata: version: 8 cacheKey: 10c0 -"@ai-sdk/provider@npm:3.0.8": - version: 3.0.8 - resolution: "@ai-sdk/provider@npm:3.0.8" - dependencies: - json-schema: "npm:^0.4.0" - checksum: 10c0/c68637c0139a6ce8af17bac1d7d539f531860026237c5c971dcecda2daa8b1e42d8c05e1e664ece60c15edb325c0253fd5b091ee54d32f870a750a493acbb0b7 - languageName: node - linkType: hard - "@bcoe/v8-coverage@npm:^1.0.1": version: 1.0.2 resolution: "@bcoe/v8-coverage@npm:1.0.2" @@ -162,15 +153,6 @@ __metadata: languageName: node linkType: hard -"@isaacs/fs-minipass@npm:^4.0.0": - version: 4.0.1 - resolution: "@isaacs/fs-minipass@npm:4.0.1" - dependencies: - minipass: "npm:^7.0.4" - checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 - languageName: node - linkType: hard - "@istanbuljs/schema@npm:^0.1.2, @istanbuljs/schema@npm:^0.1.3": version: 0.1.3 resolution: "@istanbuljs/schema@npm:0.1.3" @@ -221,84 +203,30 @@ __metadata: languageName: node linkType: hard -"@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.4": - version: 3.0.4 - resolution: "@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.4" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.4": - version: 3.0.4 - resolution: "@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.4" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.4": - version: 3.0.4 - resolution: "@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.4" - conditions: os=linux & cpu=arm64 - languageName: node - linkType: hard - -"@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.4": - version: 3.0.4 - resolution: "@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.4" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.4": - version: 3.0.4 - resolution: "@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.4" - conditions: os=linux & cpu=x64 - languageName: node - linkType: hard - -"@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.4": - version: 3.0.4 - resolution: "@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.4" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"@opencode-ai/plugin@npm:1.18.34": - version: 1.18.34 - resolution: "@opencode-ai/plugin@npm:1.18.34" +"@opencode-ai/plugin@npm:1.4.3": + version: 1.4.3 + resolution: "@opencode-ai/plugin@npm:1.4.3" dependencies: - "@ai-sdk/provider": "npm:3.0.8" - "@opencode-ai/sdk": "npm:1.18.34" - effect: "npm:4.0.0-beta.83" + "@opencode-ai/sdk": "npm:1.4.3" zod: "npm:4.1.8" peerDependencies: - "@opentui/core": ">=0.4.5" - "@opentui/keymap": ">=0.4.5" - "@opentui/solid": ">=0.4.5" + "@opentui/core": ">=0.1.97" + "@opentui/solid": ">=0.1.97" peerDependenciesMeta: "@opentui/core": optional: true - "@opentui/keymap": - optional: true "@opentui/solid": optional: true - checksum: 10c0/09929c3a34d73aba35287d559256489570af82c25494b0d6d8ebf887b9aaf5a2e9a7ff0d449ca9a9e864e223ebdb0d2da378ccd4e4c4daba8dcf98820042b47d + checksum: 10c0/a20328a691a674638e4718c1fb911ea68b60fc7560f1bf314324114ccdcabbddc12e98c4fc9f3aad69e92aaaac7edbd44216bf036955ec5d1f50282430ab06ae languageName: node linkType: hard -"@opencode-ai/sdk@npm:1.18.34": - version: 1.18.34 - resolution: "@opencode-ai/sdk@npm:1.18.34" +"@opencode-ai/sdk@npm:1.4.3": + version: 1.4.3 + resolution: "@opencode-ai/sdk@npm:1.4.3" dependencies: cross-spawn: "npm:7.0.6" - checksum: 10c0/bad0f38016e59568d5e16e9bdeddca9d491845269de5d860640132efb3a4d5612057418a4327a4264d937fa24bf914458e458f3dcd88e68b9e50951570fb68e2 - languageName: node - linkType: hard - -"@standard-schema/spec@npm:^1.1.0": - version: 1.1.0 - resolution: "@standard-schema/spec@npm:1.1.0" - checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526 + checksum: 10c0/edba27ef01ecfb6fde7df2348f953aab64f2e7b99e9cd5b155474e7e02cc0db62da242d9edcd5b704110b9ef82bc16633d99d25eaa812d4279badede71ae419f languageName: node linkType: hard @@ -369,13 +297,6 @@ __metadata: languageName: node linkType: hard -"abbrev@npm:^5.0.0": - version: 5.0.0 - resolution: "abbrev@npm:5.0.0" - checksum: 10c0/8e88f5c798ea4562d28c5a3e9ad69e3879890bc5d695d8f2dffb8609be4c890aacc8f80ef4553fdd2c6a62d70c2ce8bc57b38074e383beb7487bdafa9ed42ea5 - languageName: node - linkType: hard - "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -524,13 +445,6 @@ __metadata: languageName: node linkType: hard -"chownr@npm:^3.0.0": - version: 3.0.0 - resolution: "chownr@npm:3.0.0" - checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10 - languageName: node - linkType: hard - "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -632,13 +546,6 @@ __metadata: languageName: node linkType: hard -"detect-libc@npm:^2.0.1": - version: 2.1.2 - resolution: "detect-libc@npm:2.1.2" - checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 - languageName: node - linkType: hard - "devlop@npm:^1.0.0": version: 1.1.0 resolution: "devlop@npm:1.1.0" @@ -654,7 +561,7 @@ __metadata: dependencies: "@eslint/js": "npm:9.39.2" "@iarna/toml": "npm:2.2.5" - "@opencode-ai/plugin": "npm:1.18.34" + "@opencode-ai/plugin": "npm:1.4.3" "@types/node": "npm:26.6.4" ajv: "npm:8.20.0" c8: "npm:11.0.0" @@ -664,6 +571,7 @@ __metadata: markdownlint-cli: "npm:0.49.1" sql.js: "npm:1.14.2" typescript: "npm:6.0.3" + zod: "npm:4.6.7" bin: ecc: scripts/ecc.js ecc-control-pane: scripts/control-pane.js @@ -674,24 +582,6 @@ __metadata: languageName: unknown linkType: soft -"effect@npm:4.0.0-beta.83": - version: 4.0.0-beta.83 - resolution: "effect@npm:4.0.0-beta.83" - dependencies: - "@standard-schema/spec": "npm:^1.1.0" - fast-check: "npm:^4.8.0" - find-my-way-ts: "npm:^0.1.6" - ini: "npm:^7.0.0" - kubernetes-types: "npm:^1.30.0" - msgpackr: "npm:^2.0.1" - multipasta: "npm:^0.2.7" - toml: "npm:^4.1.1" - uuid: "npm:^14.0.0" - yaml: "npm:^2.9.0" - checksum: 10c0/1eaa03aee6e8bf7190645fc032cb7541aafe2b07ed9de8ebe13525971cd5fcea2d003b327fbed626439458dc619ef5c1371f27f6d1ec52c8acf851ead1237c22 - languageName: node - linkType: hard - "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -706,13 +596,6 @@ __metadata: languageName: node linkType: hard -"env-paths@npm:^2.2.0": - version: 2.2.1 - resolution: "env-paths@npm:2.2.1" - checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 - languageName: node - linkType: hard - "escalade@npm:^3.1.1": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -841,22 +724,6 @@ __metadata: languageName: node linkType: hard -"exponential-backoff@npm:^3.1.1": - version: 3.1.3 - resolution: "exponential-backoff@npm:3.1.3" - checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267 - languageName: node - linkType: hard - -"fast-check@npm:^4.8.0": - version: 4.8.0 - resolution: "fast-check@npm:4.8.0" - dependencies: - pure-rand: "npm:^8.0.0" - checksum: 10c0/f72556a29db4ff386a8b6e50d420b06c7e5eaafff7db5560a99136c57d8d4777998155eb02d1bbeff396f575cc0b1442c8a1c4ddb798c4a919b542de1a1904ff - languageName: node - linkType: hard - "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -878,10 +745,10 @@ __metadata: languageName: node linkType: hard -"fast-uri@npm:3.1.8": - version: 3.1.8 - resolution: "fast-uri@npm:3.1.8" - checksum: 10c0/c9c47860a7f9110176d94bb30a02942182f0bfc8d6780e9702f320336dc2dadb83b1d9879cceaf8d4b439642d1ec6d78c317cb808ab155455551bd5d9d7c22d8 +"fast-uri@npm:3.1.9": + version: 3.1.9 + resolution: "fast-uri@npm:3.1.9" + checksum: 10c0/052757540d8c0bbc16bb124a45b8f5cb5d0932f9b89f019221ee41cdb3cafc1db44b2e809d6b888b87922af3e5560a863124454e9d9193e1fc955887357b5add languageName: node linkType: hard @@ -906,13 +773,6 @@ __metadata: languageName: node linkType: hard -"find-my-way-ts@npm:^0.1.6": - version: 0.1.6 - resolution: "find-my-way-ts@npm:0.1.6" - checksum: 10c0/16ad4b15275b56ee0ec361d0c61afbdff4c75bd0ac04112f6910f188cb1058096ba63529c2363914da6bb60266aa4def1025af04af26368ff87eb0df52f2862f - languageName: node - linkType: hard - "find-up@npm:^5.0.0": version: 5.0.0 resolution: "find-up@npm:5.0.0" @@ -992,13 +852,6 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.2.6": - version: 4.2.11 - resolution: "graceful-fs@npm:4.2.11" - checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 - languageName: node - linkType: hard - "has-flag@npm:^4.0.0": version: 4.0.0 resolution: "has-flag@npm:4.0.0" @@ -1057,7 +910,7 @@ __metadata: languageName: node linkType: hard -"ini@npm:^7.0.0, ini@npm:~7.0.0": +"ini@npm:~7.0.0": version: 7.0.0 resolution: "ini@npm:7.0.0" checksum: 10c0/7520cae38bd5587e1cbca4637bb5fc0e157f38e0816522756456010ef703772d0018f9f4987cb9977bf3b10c1feccaad7800744dd1f5d85fb435e58a1baa9754 @@ -1125,13 +978,6 @@ __metadata: languageName: node linkType: hard -"isexe@npm:^4.0.0": - version: 4.0.0 - resolution: "isexe@npm:4.0.0" - checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce - languageName: node - linkType: hard - "istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0": version: 3.2.2 resolution: "istanbul-lib-coverage@npm:3.2.2" @@ -1185,13 +1031,6 @@ __metadata: languageName: node linkType: hard -"json-schema@npm:^0.4.0": - version: 0.4.0 - resolution: "json-schema@npm:0.4.0" - checksum: 10c0/d4a637ec1d83544857c1c163232f3da46912e971d5bf054ba44fdb88f07d8d359a462b4aec46f2745efbc57053365608d88bc1d7b1729f7b4fc3369765639ed3 - languageName: node - linkType: hard - "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" @@ -1233,13 +1072,6 @@ __metadata: languageName: node linkType: hard -"kubernetes-types@npm:^1.30.0": - version: 1.30.0 - resolution: "kubernetes-types@npm:1.30.0" - checksum: 10c0/de3641e4f50cfc123c4102a73c12932e1db8e51783c7cae4ea8ad3561bd56fab0f1c2346801f84a4c36aae8cea0b25d21e9514cc0fcecd4d64b1314043263076 - languageName: node - linkType: hard - "levn@npm:^0.4.1": version: 0.4.1 resolution: "levn@npm:0.4.1" @@ -1666,22 +1498,13 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^7.0.4, minipass@npm:^7.1.2, minipass@npm:^7.1.3": +"minipass@npm:^7.1.2, minipass@npm:^7.1.3": version: 7.1.3 resolution: "minipass@npm:7.1.3" checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb languageName: node linkType: hard -"minizlib@npm:^3.1.0": - version: 3.1.0 - resolution: "minizlib@npm:3.1.0" - dependencies: - minipass: "npm:^7.1.2" - checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec - languageName: node - linkType: hard - "ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" @@ -1689,56 +1512,6 @@ __metadata: languageName: node linkType: hard -"msgpackr-extract@npm:^3.0.4": - version: 3.0.4 - resolution: "msgpackr-extract@npm:3.0.4" - dependencies: - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "npm:3.0.4" - "@msgpackr-extract/msgpackr-extract-darwin-x64": "npm:3.0.4" - "@msgpackr-extract/msgpackr-extract-linux-arm": "npm:3.0.4" - "@msgpackr-extract/msgpackr-extract-linux-arm64": "npm:3.0.4" - "@msgpackr-extract/msgpackr-extract-linux-x64": "npm:3.0.4" - "@msgpackr-extract/msgpackr-extract-win32-x64": "npm:3.0.4" - node-gyp: "npm:latest" - node-gyp-build-optional-packages: "npm:5.2.2" - dependenciesMeta: - "@msgpackr-extract/msgpackr-extract-darwin-arm64": - optional: true - "@msgpackr-extract/msgpackr-extract-darwin-x64": - optional: true - "@msgpackr-extract/msgpackr-extract-linux-arm": - optional: true - "@msgpackr-extract/msgpackr-extract-linux-arm64": - optional: true - "@msgpackr-extract/msgpackr-extract-linux-x64": - optional: true - "@msgpackr-extract/msgpackr-extract-win32-x64": - optional: true - bin: - download-msgpackr-prebuilds: bin/download-prebuilds.js - checksum: 10c0/582a9d17abbf3019e600e948736695056280ce401fd0235ee2474e95f9952208b9f6cce4d0e355b03b7d3c5630e6c3d11fe5fc27fdedb2311cce48de464338d8 - languageName: node - linkType: hard - -"msgpackr@npm:^2.0.1": - version: 2.0.4 - resolution: "msgpackr@npm:2.0.4" - dependencies: - msgpackr-extract: "npm:^3.0.4" - dependenciesMeta: - msgpackr-extract: - optional: true - checksum: 10c0/b72e8de59ce82a29cd5ca6d93ceec589367dd742a75fb04b6d26d6e0c5863f1000d0b27979469f18ca4554f3ccabe620524013268d5387415bcab495cca4ae73 - languageName: node - linkType: hard - -"multipasta@npm:^0.2.7": - version: 0.2.7 - resolution: "multipasta@npm:0.2.7" - checksum: 10c0/15917ac88aeefa5b8afac44b90d1e9d0d0ec7148b51e0766f07a69a220ecebcb6404539a856c45aa85a3d7fe517bc58febe81437146705f17ecd2961dc0b9fa5 - languageName: node - linkType: hard - "natural-compare@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare@npm:1.4.0" @@ -1746,50 +1519,6 @@ __metadata: languageName: node linkType: hard -"node-gyp-build-optional-packages@npm:5.2.2": - version: 5.2.2 - resolution: "node-gyp-build-optional-packages@npm:5.2.2" - dependencies: - detect-libc: "npm:^2.0.1" - bin: - node-gyp-build-optional-packages: bin.js - node-gyp-build-optional-packages-optional: optional.js - node-gyp-build-optional-packages-test: build-test.js - checksum: 10c0/c81128c6f91873381be178c5eddcbdf66a148a6a89a427ce2bcd457593ce69baf2a8662b6d22cac092d24aa9c43c230dec4e69b3a0da604503f4777cd77e282b - languageName: node - linkType: hard - -"node-gyp@npm:latest": - version: 13.0.0 - resolution: "node-gyp@npm:13.0.0" - dependencies: - env-paths: "npm:^2.2.0" - exponential-backoff: "npm:^3.1.1" - graceful-fs: "npm:^4.2.6" - nopt: "npm:^10.0.0" - proc-log: "npm:^7.0.0" - semver: "npm:^7.3.5" - tar: "npm:^7.5.4" - tinyglobby: "npm:^0.2.12" - undici: "npm:^6.25.0" - which: "npm:^7.0.0" - bin: - node-gyp: bin/node-gyp.js - checksum: 10c0/e7525c427db2d16aa368b8947187de83083d2a8dda23e3e096a71c22ae637ac5bb8ed7cf6c871f1b9118cd2729dbfee4ff3a4245e2b79226900227b15831b492 - languageName: node - linkType: hard - -"nopt@npm:^10.0.0": - version: 10.0.1 - resolution: "nopt@npm:10.0.1" - dependencies: - abbrev: "npm:^5.0.0" - bin: - nopt: bin/nopt.js - checksum: 10c0/980d89257f9587f3e1f77877ddbf905d6aa3b738ec33e49a4fa1a059a0dd82eb28063982b150654a7ae9de386f2ead60e56172db7d37cf56de545f7392a2a26a - languageName: node - linkType: hard - "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -1875,13 +1604,6 @@ __metadata: languageName: node linkType: hard -"proc-log@npm:^7.0.0": - version: 7.0.0 - resolution: "proc-log@npm:7.0.0" - checksum: 10c0/b89c2d862604f35fec795477b0c7e376feab3ba0d4f4d291c4e959567442697cf451ac557d0623c1cc38af45a78128b983410f397a10c5d3a67f76c33de4754b - languageName: node - linkType: hard - "punycode.js@npm:^2.3.1": version: 2.3.1 resolution: "punycode.js@npm:2.3.1" @@ -1896,13 +1618,6 @@ __metadata: languageName: node linkType: hard -"pure-rand@npm:^8.0.0": - version: 8.4.0 - resolution: "pure-rand@npm:8.4.0" - checksum: 10c0/6414bbc1c6f45fb774173431c7205e79783b77cfae0e2145e741b6999363554dbd2f4210d2a5bc08683e0b2f6823198c9308766b1d0911e1dccd7beb8842f860 - languageName: node - linkType: hard - "qified@npm:^0.10.1": version: 0.10.1 resolution: "qified@npm:0.10.1" @@ -1940,15 +1655,6 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.3.5": - version: 7.8.5 - resolution: "semver@npm:7.8.5" - bin: - semver: bin/semver.js - checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c - languageName: node - linkType: hard - "semver@npm:^7.5.3": version: 7.7.4 resolution: "semver@npm:7.7.4" @@ -2050,19 +1756,6 @@ __metadata: languageName: node linkType: hard -"tar@npm:^7.5.4": - version: 7.5.22 - resolution: "tar@npm:7.5.22" - dependencies: - "@isaacs/fs-minipass": "npm:^4.0.0" - chownr: "npm:^3.0.0" - minipass: "npm:^7.1.2" - minizlib: "npm:^3.1.0" - yallist: "npm:^5.0.0" - checksum: 10c0/1311f6be85a8157ac4c9147bae43e13923d2a1aae15e4aa1bd5239e4e03d2cf53cfe103dde7f35832fbb4c938b042856bc8e9a0afd29abd05e2d1608788c4fea - languageName: node - linkType: hard - "test-exclude@npm:^8.0.0": version: 8.0.0 resolution: "test-exclude@npm:8.0.0" @@ -2074,7 +1767,7 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.12, tinyglobby@npm:~0.2.17": +"tinyglobby@npm:~0.2.17": version: 0.2.17 resolution: "tinyglobby@npm:0.2.17" dependencies: @@ -2084,13 +1777,6 @@ __metadata: languageName: node linkType: hard -"toml@npm:^4.1.1": - version: 4.3.0 - resolution: "toml@npm:4.3.0" - checksum: 10c0/4a0ad64d4ef0b47f672a7b6bd7e776b9e03ad845998968fb105f8d561c3648eaff0217baca3f562d17e865e5bd7393441a8e54528920f0c171e41fdc8dfb39d6 - languageName: node - linkType: hard - "type-check@npm:^0.4.0, type-check@npm:~0.4.0": version: 0.4.0 resolution: "type-check@npm:0.4.0" @@ -2134,13 +1820,6 @@ __metadata: languageName: node linkType: hard -"undici@npm:^6.25.0": - version: 6.29.0 - resolution: "undici@npm:6.29.0" - checksum: 10c0/3fb2117985be977ba8b6bd6a242fec915e1b1d0f9681f5adb3f508fb1717352452d65bcfd3bed09e7c776cfda403a4e8edb24a318492f8b2d793439af2b8699b - languageName: node - linkType: hard - "uri-js@npm:^4.2.2": version: 4.4.1 resolution: "uri-js@npm:4.4.1" @@ -2150,15 +1829,6 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^14.0.0": - version: 14.0.0 - resolution: "uuid@npm:14.0.0" - bin: - uuid: dist-node/bin/uuid - checksum: 10c0/a57ae7794c45005c1a9208989196c5baf79a7679c30f43c1bee9033a2c4d113a2cea216fa6fcc9663b08b0d55635df1a7c6eb7e7f3d21c3e50688c698fa39a50 - languageName: node - linkType: hard - "v8-to-istanbul@npm:^9.0.0": version: 9.3.0 resolution: "v8-to-istanbul@npm:9.3.0" @@ -2181,17 +1851,6 @@ __metadata: languageName: node linkType: hard -"which@npm:^7.0.0": - version: 7.0.0 - resolution: "which@npm:7.0.0" - dependencies: - isexe: "npm:^4.0.0" - bin: - node-which: bin/which.js - checksum: 10c0/ca0b54f198f78bbc4b7c02e34bda8d335cb352e0adb4cbca1c37b1a957af3a879a82c4c27ca6525bc942f548d8b64f816ef6528360af9f3de55ffb9b979b620d - languageName: node - linkType: hard - "word-wrap@npm:^1.2.5": version: 1.2.5 resolution: "word-wrap@npm:1.2.5" @@ -2217,22 +1876,6 @@ __metadata: languageName: node linkType: hard -"yallist@npm:^5.0.0": - version: 5.0.0 - resolution: "yallist@npm:5.0.0" - checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416 - languageName: node - linkType: hard - -"yaml@npm:^2.9.0": - version: 2.9.0 - resolution: "yaml@npm:2.9.0" - bin: - yaml: bin.mjs - checksum: 10c0/f340718df45e97a9551b9bf9dac61c80050bc464513b710debfb5067c380c8472e3b67809cffacb4ab5ffb5e66ef9310816c88b05f371cec60abfedd8c88e0a2 - languageName: node - linkType: hard - "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -2262,9 +1905,9 @@ __metadata: languageName: node linkType: hard -"zod@npm:4.1.8": - version: 4.1.8 - resolution: "zod@npm:4.1.8" - checksum: 10c0/5eae39da09d7bd0564a30dfd2348811e4e2e7dd15955d8f3444f8e196f35e5422b1482eda234b722fafb0738f4a8b718adb042b860936bfdd2cc19cdbdac8a9a +"zod@npm:4.6.7": + version: 4.6.7 + resolution: "zod@npm:4.6.7" + checksum: 10c0/4ce2eb14034317b7254e347ca37b5ad591cf8333147030cf05b9b5d31692d45073f7f2747aa50cf8c6b29012194d2b22978cecc89969cf34a0d18e89d0ee7d7b languageName: node linkType: hard