From 4aa28ccac5980d57759b50d83785dbd39d858263 Mon Sep 17 00:00:00 2001 From: ethereal49 Date: Sun, 11 Oct 2026 20:11:55 -1000 Subject: [PATCH 01/69] fix(commands): honor Codex model configuration in Santa Loop (cherry picked from commit 2109d97b4005580f43b8d9dda92a126f8899d493) --- commands/santa-loop.md | 4 +- docs/ja-JP/commands/santa-loop.md | 5 +- docs/zh-CN/commands/santa-loop.md | 4 +- tests/commands/santa-loop.test.js | 85 +++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 tests/commands/santa-loop.test.js diff --git a/commands/santa-loop.md b/commands/santa-loop.md index 2ea1ae896..e5813993c 100644 --- a/commands/santa-loop.md +++ b/commands/santa-loop.md @@ -72,6 +72,8 @@ Launch an Agent (subagent_type: `code-reviewer`, model: `opus`) with the full ru #### Reviewer B: External Model (Claude fallback if no external reviewer is ready) +Codex uses its configured model or CLI default. Set `model` in your Codex configuration to choose a reviewer model supported by your account. + Antigravity is optional and requires the external `ccg-workflow` runtime; it is not included in the base ECC install. An existing installation must provide both an executable `~/.claude/bin/codeagent-wrapper` and a readable regular reviewer role file at `~/.claude/.ccg/prompts/antigravity/reviewer.md`. Otherwise, retain the Claude fallback. Use the wrapper's maintained model-selection contract; do not add an Antigravity model pin. Run detection, prompt creation, and the selected external review together in this single Bash subshell. Replace the prompt placeholder with the same rubric, file contents, and review-only instructions given to Reviewer A. The order remains Codex, Gemini, Antigravity, then Claude. The Claude fallback creates no prompt file. @@ -116,7 +118,7 @@ EOF case "$REVIEWER_BACKEND" in codex) - codex exec --sandbox read-only -m gpt-5.4 -C "$(pwd)" - < "$PROMPT_FILE" + codex exec --sandbox read-only -C "$(pwd)" - < "$PROMPT_FILE" ;; gemini) REVIEWER_PROMPT=$(cat "$PROMPT_FILE") diff --git a/docs/ja-JP/commands/santa-loop.md b/docs/ja-JP/commands/santa-loop.md index bd024a75c..f875e1dad 100644 --- a/docs/ja-JP/commands/santa-loop.md +++ b/docs/ja-JP/commands/santa-loop.md @@ -89,8 +89,11 @@ EOF 最初に利用可能なCLIを使用します: **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" ``` diff --git a/docs/zh-CN/commands/santa-loop.md b/docs/zh-CN/commands/santa-loop.md index adafef45d..ce7262f2a 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" ``` diff --git a/tests/commands/santa-loop.test.js b/tests/commands/santa-loop.test.js new file mode 100644 index 000000000..1a56db5da --- /dev/null +++ b/tests/commands/santa-loop.test.js @@ -0,0 +1,85 @@ +'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'); + 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'), `#!${process.execPath} +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') })); +`, { mode: 0o755 }); + const result = spawnSync('bash', ['-c', invocation[0]], { + cwd: tempRoot, + env: { ...process.env, PATH: `${tempRoot}${path.delimiter}${process.env.PATH}`, + PROMPT_FILE: promptFile, ECC_TEST_CONFIGURED_MODEL: configuredModel }, + 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) { + 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); From f7c6809bd716671599f52cc020f41cf63e17195c Mon Sep 17 00:00:00 2001 From: ethereal49 Date: Thu, 15 Oct 2026 01:27:33 -1000 Subject: [PATCH 02/69] fix(commands): address Santa Loop review feedback (cherry picked from commit 3a0e3313d579188aaac22a04195eb46b24726266) --- docs/ja-JP/commands/santa-loop.md | 4 ++-- docs/zh-CN/commands/santa-loop.md | 4 ++-- tests/commands/santa-loop.test.js | 15 ++++++++++----- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/ja-JP/commands/santa-loop.md b/docs/ja-JP/commands/santa-loop.md index f875e1dad..58a1a1e0c 100644 --- a/docs/ja-JP/commands/santa-loop.md +++ b/docs/ja-JP/commands/santa-loop.md @@ -169,8 +169,8 @@ Result: [PUSHED / ESCALATED TO USER] ## 注意事項 - レビュアー A(Claude Opus)は常に実行されます — ツール環境に関係なく、少なくとも1つの強力なレビュアーが保証されます。 -- レビュアー Bの目標はモデル多様性です。GPT-5.4またはGemini 2.5 Proは真の独立性を提供します — 異なる学習データ、異なるバイアス、異なる死角。Claudeのみのフォールバックでもコンテキスト分離による価値はありますが、モデル多様性は失われます。 -- 利用可能な最も強力なモデルが使用されます:レビュアー AにはOpus、レビュアー BにはGPT-5.4またはGemini 2.5 Pro。 +- レビュアー Bの目標はモデル多様性です。選択したバックエンドが報告する実際のモデルとプロバイダーを記録してください。Claudeのみのフォールバックはコンテキストを分離しますが、モデル多様性は失われます。 +- レビュアー AにはOpusを使用します。レビュアー BのCodexは設定されたモデル、またはCLIのデフォルトを使用します。 - 外部レビュアーはレビュー中のリポジトリ変更を防ぐため、`--sandbox read-only`(Codex)で実行されます。 - 各ラウンドで新しいレビュアーを使用することで、以前の発見に対するアンカリングバイアスを防ぎます。 - ルーブリックは最も重要な入力です。レビュアーがゴム印承認をしたり、主観的なスタイルの問題をフラグに立てる場合は、ルーブリックを厳格化してください。 diff --git a/docs/zh-CN/commands/santa-loop.md b/docs/zh-CN/commands/santa-loop.md index ce7262f2a..14e176587 100644 --- a/docs/zh-CN/commands/santa-loop.md +++ b/docs/zh-CN/commands/santa-loop.md @@ -173,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/tests/commands/santa-loop.test.js b/tests/commands/santa-loop.test.js index 1a56db5da..a1ae5a2d2 100644 --- a/tests/commands/santa-loop.test.js +++ b/tests/commands/santa-loop.test.js @@ -40,8 +40,7 @@ function runCodexExample(commandPath, configuredModel) { try { const promptFile = path.join(tempRoot, 'prompt.md'); fs.writeFileSync(promptFile, 'Independent review only.'); - fs.writeFileSync(path.join(tempRoot, 'codex'), `#!${process.execPath} -const fs = require('fs'); + 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]; @@ -50,11 +49,13 @@ if (model !== process.env.ECC_TEST_CONFIGURED_MODEL) { process.exit(1); } console.log(JSON.stringify({ args, model, prompt: fs.readFileSync(0, 'utf8') })); -`, { mode: 0o755 }); - const result = spawnSync('bash', ['-c', invocation[0]], { +`); + 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 }, + PROMPT_FILE: promptFile, ECC_TEST_CONFIGURED_MODEL: configuredModel, ECC_TEST_NODE: process.execPath }, encoding: 'utf8', timeout: 10000, }); assert.strictEqual(result.status, 0, result.stderr); @@ -67,6 +68,10 @@ console.log(JSON.stringify({ args, model, prompt: fs.readFileSync(0, 'utf8') })) 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); From b46a2ac58eb852f067246084515724cd56862925 Mon Sep 17 00:00:00 2001 From: edilman38-cyber <234881225+edilman38-cyber@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:22:20 +0300 Subject: [PATCH 03/69] docs(skills): fix Preferrably typo in remotion fonts rule (cherry picked from commit 4a39c52dfb37dd10b62632b374aa9ae8df12a3d8) --- skills/remotion-video-creation/rules/fonts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"; From 2144e787612b32fc1e5131b343b3b38b6c5d2e92 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:26:06 -0400 Subject: [PATCH 04/69] ci: refresh skill trigger digest for remotion typo (cherry picked from commit e7e07e7dbe668e7b9771a0512a990d6f7cb3d611) --- manifests/context-packs/skill-triggers@1.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index a49a8c824..e878d7d96 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "498caee2738006d64530e9be34883111cf67742d8c4389f39d61299e1a1c4400", + "registryDigest": "ab92a817086843427f1dd8b7b9a96799f1f7095e1366e244272ebea0936f8fc9", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [ From 63cc08332dc5cc31999018d10c2d19ee7b8318d3 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:35:45 +0200 Subject: [PATCH 05/69] docs: use effort controls in adaptive-thinking workarounds (cherry picked from commit b1523ea23f5d29511398ee7006f37248f61c04d3) --- docs/TROUBLESHOOTING.md | 2 +- docs/hook-bug-workarounds.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 7c23b0a54..175c8e15f 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -62,7 +62,7 @@ exit 2 **What helps:** - Reduce tool-definition pressure with `ENABLE_TOOL_SEARCH=auto:5` if your setup supports it. -- Lower `MAX_THINKING_TOKENS` for routine work. +- Lower the supported effort level (for example, `/effort low`) for routine work on adaptive-reasoning models; `MAX_THINKING_TOKENS` only caps fixed-budget thinking. - Route subagent work to a cheaper model such as `CLAUDE_CODE_SUBAGENT_MODEL=haiku` if your setup exposes that knob. - Disable unused MCP servers per project. - Compact manually at natural breakpoints instead of waiting for auto-compaction. diff --git a/docs/hook-bug-workarounds.md b/docs/hook-bug-workarounds.md index ef1d4fbb1..7746dfa6c 100644 --- a/docs/hook-bug-workarounds.md +++ b/docs/hook-bug-workarounds.md @@ -61,7 +61,7 @@ What helps: What helps: - Reduce tool-definition pressure with `ENABLE_TOOL_SEARCH=auto:5` if your setup supports it. -- Lower `MAX_THINKING_TOKENS` for routine work. +- Lower the supported effort level (for example, `/effort low`) for routine work on adaptive-reasoning models; `MAX_THINKING_TOKENS` only caps fixed-budget thinking. - Route subagent work to a cheaper model such as `CLAUDE_CODE_SUBAGENT_MODEL=haiku` if your setup exposes that knob. - Disable unused MCP servers per project. - Compact manually at natural breakpoints instead of waiting for auto-compaction. From 5367671a2290af325ad28cee392171fdd96c12e0 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:16:00 +0200 Subject: [PATCH 06/69] fix: reject missing session inspection option values (cherry picked from commit a6619f5efd47a97c4f95c6e541f159543a7cfd0d) --- scripts/session-inspect.js | 8 ++++- tests/scripts/session-inspect.test.js | 45 ++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 9 deletions(-) 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/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 }); From 5aa13c7d33f818a83cf79268410f621ce99f1b4d Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:46:25 +0200 Subject: [PATCH 07/69] docs: distinguish Flask and FastAPI testing fixtures (cherry picked from commit 2353740d9eef728c1560a22602ba4801cea1d77a) --- pi/core/skills/python-testing/SKILL.md | 16 ++++++++++------ skills/python-testing/SKILL.md | 16 ++++++++++------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/pi/core/skills/python-testing/SKILL.md b/pi/core/skills/python-testing/SKILL.md index ddfcc0abc..b9e7d7437 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,14 +484,18 @@ async def test_async_with_fixture(async_client): assert response.status_code == 200 ``` -### Async Fixture +### Async Fixture (FastAPI) ```python -@pytest.fixture +import pytest +import pytest_asyncio +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: + app = create_app() # Returns a FastAPI application + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: yield client @pytest.mark.asyncio @@ -661,7 +665,7 @@ class TestUserService: ## Common Patterns -### Testing API Endpoints (FastAPI/Flask) +### Testing API Endpoints (Flask) ```python @pytest.fixture diff --git a/skills/python-testing/SKILL.md b/skills/python-testing/SKILL.md index ddfcc0abc..b9e7d7437 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,14 +484,18 @@ async def test_async_with_fixture(async_client): assert response.status_code == 200 ``` -### Async Fixture +### Async Fixture (FastAPI) ```python -@pytest.fixture +import pytest +import pytest_asyncio +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: + app = create_app() # Returns a FastAPI application + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: yield client @pytest.mark.asyncio @@ -661,7 +665,7 @@ class TestUserService: ## Common Patterns -### Testing API Endpoints (FastAPI/Flask) +### Testing API Endpoints (Flask) ```python @pytest.fixture From 633aed12e2b6369908b296b18c0e159bfc9d7862 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:14:12 +0200 Subject: [PATCH 08/69] docs: explain FastAPI async fixture lifespan management (cherry picked from commit 26b0523875173ff1bb3caf8b8eb719fa79ca8d15) --- pi/core/skills/python-testing/SKILL.md | 3 +++ skills/python-testing/SKILL.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/pi/core/skills/python-testing/SKILL.md b/pi/core/skills/python-testing/SKILL.md index b9e7d7437..713aeb429 100644 --- a/pi/core/skills/python-testing/SKILL.md +++ b/pi/core/skills/python-testing/SKILL.md @@ -486,6 +486,9 @@ async def test_async_with_fixture(async_client): ### Async Fixture (FastAPI) +`ASGITransport` does not run lifespan events. If the app needs startup/shutdown, wrap the client block in `async with LifespanManager(app) as manager:` (from `asgi_lifespan`) +and use `ASGITransport(app=manager.app)`. See [FastAPI's async testing guidance](https://github.com/fastapi/fastapi/blob/master/docs/en/docs/advanced/async-tests.md). + ```python import pytest import pytest_asyncio diff --git a/skills/python-testing/SKILL.md b/skills/python-testing/SKILL.md index b9e7d7437..713aeb429 100644 --- a/skills/python-testing/SKILL.md +++ b/skills/python-testing/SKILL.md @@ -486,6 +486,9 @@ async def test_async_with_fixture(async_client): ### Async Fixture (FastAPI) +`ASGITransport` does not run lifespan events. If the app needs startup/shutdown, wrap the client block in `async with LifespanManager(app) as manager:` (from `asgi_lifespan`) +and use `ASGITransport(app=manager.app)`. See [FastAPI's async testing guidance](https://github.com/fastapi/fastapi/blob/master/docs/en/docs/advanced/async-tests.md). + ```python import pytest import pytest_asyncio From 88871370c598b36eb6fa6ea1b2919f057bad64b5 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:52:43 +0200 Subject: [PATCH 09/69] docs: preserve the Chinese command template code fence (cherry picked from commit 8b4097e18ec335356489ee84a2bd530135ceddf1) --- docs/zh-CN/CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 中显示的简要描述 用户将收到的内容。 -``` +```` ### 命令示例 From e4666abb018df234550f26d30d5cba0eabbb54df Mon Sep 17 00:00:00 2001 From: Gaurav Dubey Date: Tue, 13 Oct 2026 20:37:11 +0000 Subject: [PATCH 10/69] fix(opencode): ship @opencode-ai/plugin as a runtime dependency The compiled OpenCode tools import `tool` from `@opencode-ai/plugin/tool` at module top level, and the hooks plugin imports those tools statically. The package was only a devDependency, so production installs (OpenCode's npm plugin cache, `npm install --omit=dev`) could not resolve it and the whole plugin failed to load. Move it to `dependencies` and add a build-opencode regression test that fails when the built bundle imports a package not declared as a runtime dependency. Fixes #3543 (cherry picked from commit 106024070efdc3fc1091c10b735cc24dac4a79a6) --- package-lock.json | 34 +--------------------------- package.json | 2 +- tests/scripts/build-opencode.test.js | 31 +++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index 29f141c7a..bb4dcbd04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@iarna/toml": "2.2.5", + "@opencode-ai/plugin": "1.18.34", "ajv": "8.20.0", "js-yaml": "4.3.2", "sql.js": "1.14.2" @@ -24,7 +25,6 @@ }, "devDependencies": { "@eslint/js": "9.39.2", - "@opencode-ai/plugin": "1.18.34", "@types/node": "26.6.4", "c8": "11.0.0", "eslint": "10.11.0", @@ -40,7 +40,6 @@ "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" @@ -344,7 +343,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -358,7 +356,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -372,7 +369,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -386,7 +382,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -400,7 +395,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -414,7 +408,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -425,7 +418,6 @@ "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==", - "dev": true, "license": "MIT", "dependencies": { "@ai-sdk/provider": "3.0.8", @@ -454,7 +446,6 @@ "version": "1.18.34", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.34.tgz", "integrity": "sha512-R9QWUpcngtdQ6FCixz7NC6n1WsSho6CEuRUzYHz/CzZcp3miSklDzXkm0oDwXVCnuEu+ANoeM0f6RDp51kOMzQ==", - "dev": true, "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -464,7 +455,6 @@ "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": { @@ -826,7 +816,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -900,7 +889,6 @@ "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": { @@ -925,7 +913,6 @@ "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", @@ -1165,7 +1152,6 @@ "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", @@ -1252,7 +1238,6 @@ "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": { @@ -1436,7 +1421,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", - "dev": true, "license": "ISC", "engines": { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" @@ -1527,7 +1511,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -1595,7 +1578,6 @@ "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": { @@ -1669,7 +1651,6 @@ "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": { @@ -2426,7 +2407,6 @@ "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" @@ -2436,7 +2416,6 @@ "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, @@ -2459,7 +2438,6 @@ "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": { @@ -2473,7 +2451,6 @@ "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": { @@ -2569,7 +2546,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2638,7 +2614,6 @@ "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", @@ -2723,7 +2698,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -2736,7 +2710,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2869,7 +2842,6 @@ "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" @@ -2930,7 +2902,6 @@ "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" @@ -2959,7 +2930,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -2995,7 +2965,6 @@ "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" @@ -3091,7 +3060,6 @@ "version": "4.1.8", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index f30693419..10f5641f3 100644 --- a/package.json +++ b/package.json @@ -543,6 +543,7 @@ }, "dependencies": { "@iarna/toml": "2.2.5", + "@opencode-ai/plugin": "1.18.34", "ajv": "8.20.0", "js-yaml": "4.3.2", "sql.js": "1.14.2" @@ -560,7 +561,6 @@ }, "devDependencies": { "@eslint/js": "9.39.2", - "@opencode-ai/plugin": "1.18.34", "@types/node": "26.6.4", "c8": "11.0.0", "eslint": "10.11.0", 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") From 6a8e522fbb947635879207c8fc42c92a5d9a7855 Mon Sep 17 00:00:00 2001 From: arvindvishwakarma001 <220716827+arvindvishwakarma001@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:50:33 +0530 Subject: [PATCH 11/69] docs: add missing sections to CONTRIBUTING table of contents (cherry picked from commit 1859fd79d0b2e324eb85ab4c3fffbd7f4a3d0435) --- CONTRIBUTING.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8fe695388..62687d3d2 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) --- From 07051ec5c7ddb5825e191c2a4c87ce2237ef7bb8 Mon Sep 17 00:00:00 2001 From: Uvais Bughari <100007400+Uvaisbugh@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:50:07 +0530 Subject: [PATCH 12/69] docs: fix stale ecc-universal version pin in ANTIGRAVITY-GUIDE The Quick Start section pinned ecc-universal@2.2.0 while every other reference in the repo (README.md, and all other docs) uses @2.2.3. This caused Antigravity users to install an outdated version. Update the pin to @2.2.3 to match the rest of the codebase. (cherry picked from commit a052b208e55c64b5d8e6dc905c80060e06c33132) --- docs/ANTIGRAVITY-GUIDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ANTIGRAVITY-GUIDE.md b/docs/ANTIGRAVITY-GUIDE.md index 998915216..f16aa717d 100644 --- a/docs/ANTIGRAVITY-GUIDE.md +++ b/docs/ANTIGRAVITY-GUIDE.md @@ -15,7 +15,7 @@ from the project you want to configure: ```bash npm view ecc-universal version -npx ecc-universal@2.2.0 install --profile minimal --target antigravity +npx ecc-universal@2.2.3 install --profile minimal --target antigravity ``` ### Source checkout alternative From 94b0844e9cf74e320b1546983a806fef9f884011 Mon Sep 17 00:00:00 2001 From: Uvais Bughari <100007400+Uvaisbugh@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:59:47 +0530 Subject: [PATCH 13/69] docs: also fix prose version reference flagged by CodeRabbit Line 13 still said 'Verify that 2.2.0 is readable' after the install command was updated to @2.2.3. Align prose with the pin to remove the version mismatch within the Quick Start section. (cherry picked from commit b0b955541ff1eefb5294bef937534d0d1216f530) --- docs/ANTIGRAVITY-GUIDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ANTIGRAVITY-GUIDE.md b/docs/ANTIGRAVITY-GUIDE.md index f16aa717d..efab6b5ed 100644 --- a/docs/ANTIGRAVITY-GUIDE.md +++ b/docs/ANTIGRAVITY-GUIDE.md @@ -10,7 +10,7 @@ below. ## Quick start -Verify that 2.2.0 is readable from the registry, then run the pinned package +Verify that 2.2.3 is readable from the registry, then run the pinned package from the project you want to configure: ```bash From bf49b20a714fe82a43be3fd32679a309a85776a3 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:19:14 -0400 Subject: [PATCH 14/69] docs: pin Antigravity registry check to 2.2.3 (cherry picked from commit 8af603b814d8fd11da530c2c4e5dd99403a1b5a2) --- docs/ANTIGRAVITY-GUIDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ANTIGRAVITY-GUIDE.md b/docs/ANTIGRAVITY-GUIDE.md index efab6b5ed..7e4926004 100644 --- a/docs/ANTIGRAVITY-GUIDE.md +++ b/docs/ANTIGRAVITY-GUIDE.md @@ -14,7 +14,7 @@ Verify that 2.2.3 is readable from the registry, then run the pinned package from the project you want to configure: ```bash -npm view ecc-universal version +npm view ecc-universal@2.2.3 version npx ecc-universal@2.2.3 install --profile minimal --target antigravity ``` From acc24a226f8d19e3e6a17535d304f8d570acacf5 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:39:31 -0400 Subject: [PATCH 15/69] test: align Antigravity guide check with pinned release (cherry picked from commit da132808b481ffeb5f631eab31c3f0bddb23e2c1) --- tests/docs/antigravity-guide.test.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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( From 9824f422d29cb7a58aba6384dc50bb1b3c2a5562 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:43:43 +0200 Subject: [PATCH 16/69] fix: normalize Windows file paths in failure patterns (cherry picked from commit e0de4739a92fe31a7c8e11d96b03d08a4acfc4d6) --- scripts/lib/inspection.js | 2 +- tests/lib/inspection.test.js | 25 ++++++++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/scripts/lib/inspection.js b/scripts/lib/inspection.js index e6f2cdf33..21d89a7b4 100644 --- a/scripts/lib/inspection.js +++ b/scripts/lib/inspection.js @@ -22,7 +22,7 @@ function normalizeFailureReason(reason) { // 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, '') + .replace(/(?:\b[a-z]:)?[\\/][\w./\\-]+/g, '') // Collapse whitespace .replace(/\s+/g, ' ') .trim(); diff --git a/tests/lib/inspection.test.js b/tests/lib/inspection.test.js index f8725f37c..bf948cf8f 100644 --- a/tests/lib/inspection.test.js +++ b/tests/lib/inspection.test.js @@ -58,9 +58,15 @@ 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`, + ]) { + 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 () => { @@ -171,6 +177,19 @@ 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`File not found: C:\project-a\one.js` }), + makeSkillRun({ id: 'r2', failureReason: 'File not found: D:/project-b/two.js' }), + makeSkillRun({ id: 'r3', failureReason: String.raw`File not found: E:\project-c\three.js` }), + ]; + const patterns = detectPatterns(runs); + assert.strictEqual(patterns.length, 1); + assert.strictEqual(patterns[0].count, 3); + assert.strictEqual(patterns[0].normalizedReason, 'file not found: '); + assert.strictEqual(generateReport(patterns).status, 'attention_needed'); + })) 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' }), From 0931f66775b0f6451628d8241409aabe2dae869c Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:22:29 +0200 Subject: [PATCH 17/69] fix: normalize Windows paths containing spaces and Unicode (cherry picked from commit 25b775edc1d63f5373ac6bd71404f65198633d5e) --- scripts/lib/inspection.js | 6 ++++- tests/lib/inspection.test.js | 46 ++++++++++++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/scripts/lib/inspection.js b/scripts/lib/inspection.js index 21d89a7b4..2150ccf83 100644 --- a/scripts/lib/inspection.js +++ b/scripts/lib/inspection.js @@ -21,7 +21,11 @@ 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 + // Quoting disambiguates spaces in the final Windows path component. + .replace(/(["'])(?:[a-z]:[\\/]|\\\\)[^\r\n]*?\1/gu, '$1$1') + // Directories may contain spaces, but trailing whitespace separates diagnostic prose. + .replace(/(?:\b[a-z]:|\\)[\\/](?:[^\\/:*?"<>|\r\n]*[^\s\\/:*?"<>|][\\/])*[^\s\\/:*?"<>|(),;]+/gu, '') + // Strip remaining file paths .replace(/(?:\b[a-z]:)?[\\/][\w./\\-]+/g, '') // Collapse whitespace .replace(/\s+/g, ' ') diff --git a/tests/lib/inspection.test.js b/tests/lib/inspection.test.js index bf948cf8f..f354ef6d6 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, @@ -63,6 +64,8 @@ async function runTests() { 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`, ]) { assert.strictEqual(normalizeFailureReason(reason), 'file not found: '); } @@ -73,6 +76,38 @@ async function runTests() { 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`, + ]) { + 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 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`, + ]) { + 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 '); })) passed += 1; else failed += 1; if (await test('groupFailures groups by skillId and normalized reason', async () => { @@ -179,15 +214,15 @@ async function runTests() { if (await test('detectPatterns groups failures from different Windows paths', async () => { const runs = [ - makeSkillRun({ id: 'r1', failureReason: String.raw`File not found: C:\project-a\one.js` }), - makeSkillRun({ id: 'r2', failureReason: 'File not found: D:/project-b/two.js' }), - makeSkillRun({ id: 'r3', failureReason: String.raw`File not found: E:\project-c\three.js` }), + makeSkillRun({ id: 'r1', failureReason: String.raw`Cannot open C:\Users\Alice Smith\one.js: permission denied` }), + makeSkillRun({ id: 'r2', failureReason: 'Cannot open D:/Users/Bob Jones/two.js: permission denied' }), + makeSkillRun({ id: 'r3', failureReason: String.raw`Cannot open E:\Users\Carol White\three.js: permission denied` }), ]; const patterns = detectPatterns(runs); assert.strictEqual(patterns.length, 1); assert.strictEqual(patterns[0].count, 3); - assert.strictEqual(patterns[0].normalizedReason, 'file not found: '); - assert.strictEqual(generateReport(patterns).status, 'attention_needed'); + assert.strictEqual(patterns[0].normalizedReason, 'cannot open : permission denied'); + assert.strictEqual(inspect({ getStatus: () => ({ skillRuns: { recent: runs } }) }).status, 'attention_needed'); })) passed += 1; else failed += 1; if (await test('detectPatterns tracks unique session IDs and versions', async () => { @@ -209,6 +244,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 () => { From 58b191c25d771b5454e49cb2131aa660f385c0a6 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:05:44 +0200 Subject: [PATCH 18/69] fix: normalize trailing Windows paths and spaced filenames (cherry picked from commit c31b1dd80c57e5fd0123d80c0486b9f0707c38c1) --- scripts/lib/inspection.js | 5 +++-- tests/lib/inspection.test.js | 20 +++++++++++++++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/scripts/lib/inspection.js b/scripts/lib/inspection.js index 2150ccf83..e3e257b06 100644 --- a/scripts/lib/inspection.js +++ b/scripts/lib/inspection.js @@ -21,10 +21,11 @@ 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, '') - // Quoting disambiguates spaces in the final Windows path component. + // Quoted Windows paths preserve spaces in the final path component. .replace(/(["'])(?:[a-z]:[\\/]|\\\\)[^\r\n]*?\1/gu, '$1$1') // Directories may contain spaces, but trailing whitespace separates diagnostic prose. - .replace(/(?:\b[a-z]:|\\)[\\/](?:[^\\/:*?"<>|\r\n]*[^\s\\/:*?"<>|][\\/])*[^\s\\/:*?"<>|(),;]+/gu, '') + // A colon delimits spaced leaves; trailing separators belong to the path. + .replace(/(?:\b[a-z]:|\\)[\\/](?:[^\\/:*?"<>|\r\n]*[^\s\\/:*?"<>|][\\/])*[^\s\\/:*?"<>|(),;]+(?: [^\\/:*?"<>|\r\n]+(?=:))?[\\/]*/gu, '') // Strip remaining file paths .replace(/(?:\b[a-z]:)?[\\/][\w./\\-]+/g, '') // Collapse whitespace diff --git a/tests/lib/inspection.test.js b/tests/lib/inspection.test.js index f354ef6d6..9199a8734 100644 --- a/tests/lib/inspection.test.js +++ b/tests/lib/inspection.test.js @@ -66,6 +66,9 @@ async function runTests() { 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: '); } @@ -97,6 +100,9 @@ async function runTests() { 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`, ]) { assert.strictEqual(normalizeFailureReason(`${filePath}: permission denied`), ': permission denied'); } @@ -108,6 +114,8 @@ async function runTests() { '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'); })) passed += 1; else failed += 1; if (await test('groupFailures groups by skillId and normalized reason', async () => { @@ -214,15 +222,21 @@ async function runTests() { 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\one.js: permission denied` }), - makeSkillRun({ id: 'r2', failureReason: 'Cannot open D:/Users/Bob Jones/two.js: permission denied' }), - makeSkillRun({ id: 'r3', failureReason: String.raw`Cannot open E:\Users\Carol White\three.js: permission denied` }), + 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'); })) passed += 1; else failed += 1; if (await test('detectPatterns tracks unique session IDs and versions', async () => { From 9388e06ec7bfcbdfce0f263a2ec5c2597216d216 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:02:31 +0200 Subject: [PATCH 19/69] fix: preserve diagnostics after extensionless Windows paths (cherry picked from commit 2a2e6e3848a0c554dc00663fe9a4c7272121dd16) --- scripts/lib/inspection.js | 4 ++-- tests/lib/inspection.test.js | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/lib/inspection.js b/scripts/lib/inspection.js index e3e257b06..798ff2ce7 100644 --- a/scripts/lib/inspection.js +++ b/scripts/lib/inspection.js @@ -24,8 +24,8 @@ function normalizeFailureReason(reason) { // Quoted Windows paths preserve spaces in the final path component. .replace(/(["'])(?:[a-z]:[\\/]|\\\\)[^\r\n]*?\1/gu, '$1$1') // Directories may contain spaces, but trailing whitespace separates diagnostic prose. - // A colon delimits spaced leaves; trailing separators belong to the path. - .replace(/(?:\b[a-z]:|\\)[\\/](?:[^\\/:*?"<>|\r\n]*[^\s\\/:*?"<>|][\\/])*[^\s\\/:*?"<>|(),;]+(?: [^\\/:*?"<>|\r\n]+(?=:))?[\\/]*/gu, '') + // Spaced leaves need an extension before a colon, or quotes; preserve extensionless diagnostics. + .replace(/(?:\b[a-z]:|\\)[\\/](?:[^\\/:*?"<>|\r\n]*[^\s\\/:*?"<>|][\\/])*[^\s\\/:*?"<>|(),;]+(?: [^\\/:*?"<>|\r\n]*\.[\p{L}\p{N}_-]+(?=:))?[\\/]*/gu, '') // Strip remaining file paths .replace(/(?:\b[a-z]:)?[\\/][\w./\\-]+/g, '') // Collapse whitespace diff --git a/tests/lib/inspection.test.js b/tests/lib/inspection.test.js index 9199a8734..7e92f72aa 100644 --- a/tests/lib/inspection.test.js +++ b/tests/lib/inspection.test.js @@ -87,6 +87,7 @@ async function runTests() { 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`), @@ -116,6 +117,9 @@ async function runTests() { ' 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 () => { @@ -138,6 +142,18 @@ 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('groupFailures handles mixed outcome casing', async () => { const runs = [ makeSkillRun({ id: 'r1', outcome: 'FAILURE', failureReason: 'timeout' }), From c73353e2c7166371293e9fdfe3636946476dfa1d Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:17:36 +0200 Subject: [PATCH 20/69] fix: normalize Windows backup filename suffixes (cherry picked from commit f685582ebe8b6ab79b6baa04f475eb5e0c5d26e1) --- scripts/lib/inspection.js | 2 +- tests/lib/inspection.test.js | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/lib/inspection.js b/scripts/lib/inspection.js index 798ff2ce7..8308c6b67 100644 --- a/scripts/lib/inspection.js +++ b/scripts/lib/inspection.js @@ -25,7 +25,7 @@ function normalizeFailureReason(reason) { .replace(/(["'])(?:[a-z]:[\\/]|\\\\)[^\r\n]*?\1/gu, '$1$1') // Directories may contain spaces, but trailing whitespace separates diagnostic prose. // Spaced leaves need an extension before a colon, or quotes; preserve extensionless diagnostics. - .replace(/(?:\b[a-z]:|\\)[\\/](?:[^\\/:*?"<>|\r\n]*[^\s\\/:*?"<>|][\\/])*[^\s\\/:*?"<>|(),;]+(?: [^\\/:*?"<>|\r\n]*\.[\p{L}\p{N}_-]+(?=:))?[\\/]*/gu, '') + .replace(/(?:\b[a-z]:|\\)[\\/](?:[^\\/:*?"<>|\r\n]*[^\s\\/:*?"<>|][\\/])*[^\s\\/:*?"<>|(),;]+(?: [^\\/:*?"<>|\r\n]*\.[^\s\\/:*?"<>|]+(?=:))?[\\/]*/gu, '') // Strip remaining file paths .replace(/(?:\b[a-z]:)?[\\/][\w./\\-]+/g, '') // Collapse whitespace diff --git a/tests/lib/inspection.test.js b/tests/lib/inspection.test.js index 7e92f72aa..d6c806c38 100644 --- a/tests/lib/inspection.test.js +++ b/tests/lib/inspection.test.js @@ -104,6 +104,9 @@ async function runTests() { 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'); } @@ -253,6 +256,15 @@ async function runTests() { 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('detectPatterns tracks unique session IDs and versions', async () => { From 14693070a12ec168effbebe5b080d2ae4ca87ad0 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:14:37 -0400 Subject: [PATCH 21/69] fix: normalize apostrophes inside quoted Windows paths (cherry picked from commit 8ab9358e18f28160436385c7b6d0ca876d4d49ce) --- scripts/lib/inspection.js | 5 +++-- tests/lib/inspection.test.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/scripts/lib/inspection.js b/scripts/lib/inspection.js index 8308c6b67..43ed7046e 100644 --- a/scripts/lib/inspection.js +++ b/scripts/lib/inspection.js @@ -21,8 +21,9 @@ 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, '') - // Quoted Windows paths preserve spaces in the final path component. - .replace(/(["'])(?:[a-z]:[\\/]|\\\\)[^\r\n]*?\1/gu, '$1$1') + // 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. // Spaced leaves need an extension before a colon, or quotes; preserve extensionless diagnostics. .replace(/(?:\b[a-z]:|\\)[\\/](?:[^\\/:*?"<>|\r\n]*[^\s\\/:*?"<>|][\\/])*[^\s\\/:*?"<>|(),;]+(?: [^\\/:*?"<>|\r\n]*\.[^\s\\/:*?"<>|]+(?=:))?[\\/]*/gu, '') diff --git a/tests/lib/inspection.test.js b/tests/lib/inspection.test.js index d6c806c38..9005dad28 100644 --- a/tests/lib/inspection.test.js +++ b/tests/lib/inspection.test.js @@ -96,6 +96,21 @@ async function runTests() { } })) 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`, @@ -267,6 +282,20 @@ async function runTests() { 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' }), From 0fe058561961f04346db93d15643aa2d08d163d9 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:38:46 -0400 Subject: [PATCH 22/69] ci: refresh combined skill trigger registry digest --- manifests/context-packs/skill-triggers@1.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index e878d7d96..62df0662b 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "ab92a817086843427f1dd8b7b9a96799f1f7095e1366e244272ebea0936f8fc9", + "registryDigest": "eb958f9f43b73851550a2142dc5c5da693bba33bf1c8267db3d9722cee4b891b", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [ From 150348a57adb38d09798ace0d981127f5a25a0b6 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:00:53 +0200 Subject: [PATCH 23/69] docs: repair localized development workflow links (cherry picked from commit 28289f5b419e60030fbdb7b503fbedb807890502) --- docs/ko-KR/rules/git-workflow.md | 2 +- docs/pt-BR/rules/git-workflow.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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). From 014fcae682fe2272f019c7096186e4160ad73f36 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:05:26 +0200 Subject: [PATCH 24/69] docs: repair localized brand voice schema links (cherry picked from commit fa932aa35ee71b873fff2493b433bcd2a3202197) --- docs/ja-JP/skills/brand-voice/SKILL.md | 2 +- docs/zh-CN/skills/brand-voice/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ja-JP/skills/brand-voice/SKILL.md b/docs/ja-JP/skills/brand-voice/SKILL.md index 8e189660e..73ac9eefa 100644 --- a/docs/ja-JP/skills/brand-voice/SKILL.md +++ b/docs/ja-JP/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/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)中的架构。 保持档案结构化且足够简短,以便在会话上下文中复用。重点不是文学批评,而是操作复用。 From 3a26147843f3a2df67dcb8506a26ce74e16d9c0b Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:16:31 +0200 Subject: [PATCH 25/69] docs: repair Japanese guide image URLs (cherry picked from commit 34574239f8965567ff31de6acd9b2436bbddbeaf) --- docs/ja-JP/the-longform-guide.md | 18 +++++++++--------- docs/ja-JP/the-security-guide.md | 2 +- docs/ja-JP/the-shortform-guide.md | 20 ++++++++++---------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/ja-JP/the-longform-guide.md b/docs/ja-JP/the-longform-guide.md index 18bd6f4a4..5cafc05f3 100644 --- a/docs/ja-JP/the-longform-guide.md +++ b/docs/ja-JP/the-longform-guide.md @@ -1,12 +1,12 @@ # ECC 長文ガイド -![Header: The Longform Guide to ECC](./assets/images/longform/01-header.png) +![Header: The Longform Guide to ECC](https://raw.githubusercontent.com/affaan-m/ECC/main/assets/images/longform/01-header.png) --- > **前提条件**: このガイドは[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ルートディレクトリでのステータスライン例* ``` From c2a28b296316ae167e8a0a4d645ffd4670e30dbe Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:52:06 +0200 Subject: [PATCH 26/69] fix: order failure pattern timestamps chronologically (cherry picked from commit 075d55e9cb3138c41a0bc1298095bc1e3fd765d9) --- scripts/lib/inspection.js | 4 ++-- tests/lib/inspection.test.js | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/scripts/lib/inspection.js b/scripts/lib/inspection.js index 43ed7046e..3f118f062 100644 --- a/scripts/lib/inspection.js +++ b/scripts/lib/inspection.js @@ -85,7 +85,7 @@ function detectPatterns(skillRuns, options = {}) { } const sortedRuns = [...group.runs].sort( - (a, b) => (b.createdAt || '').localeCompare(a.createdAt || '') + (a, b) => (Date.parse(b.createdAt) || 0) - (Date.parse(a.createdAt) || 0) ); const firstSeen = sortedRuns[sortedRuns.length - 1].createdAt || null; @@ -112,7 +112,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 (Date.parse(b.lastSeen) || 0) - (Date.parse(a.lastSeen) || 0); }); } diff --git a/tests/lib/inspection.test.js b/tests/lib/inspection.test.js index 9005dad28..bca5ef284 100644 --- a/tests/lib/inspection.test.js +++ b/tests/lib/inspection.test.js @@ -212,6 +212,26 @@ 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 uses default threshold', async () => { const runs = Array.from({ length: DEFAULT_FAILURE_THRESHOLD }, (_, i) => makeSkillRun({ id: `r${i}`, failureReason: 'permission denied' }) From 3ac640f6ccf46bd70520a3c6dde76734ea31b338 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:33:09 +0200 Subject: [PATCH 27/69] fix: sort missing inspection dates after pre-epoch timestamps (cherry picked from commit 774dfd689437515e6c289ed7b053a270694449fc) --- scripts/lib/inspection.js | 12 ++++++++++-- tests/lib/inspection.test.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/scripts/lib/inspection.js b/scripts/lib/inspection.js index 3f118f062..d09fbe13b 100644 --- a/scripts/lib/inspection.js +++ b/scripts/lib/inspection.js @@ -66,6 +66,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. * @@ -85,7 +93,7 @@ function detectPatterns(skillRuns, options = {}) { } const sortedRuns = [...group.runs].sort( - (a, b) => (Date.parse(b.createdAt) || 0) - (Date.parse(a.createdAt) || 0) + (a, b) => compareTimestampsDescending(a.createdAt, b.createdAt) ); const firstSeen = sortedRuns[sortedRuns.length - 1].createdAt || null; @@ -112,7 +120,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 (Date.parse(b.lastSeen) || 0) - (Date.parse(a.lastSeen) || 0); + return compareTimestampsDescending(a.lastSeen, b.lastSeen); }); } diff --git a/tests/lib/inspection.test.js b/tests/lib/inspection.test.js index bca5ef284..04198f406 100644 --- a/tests/lib/inspection.test.js +++ b/tests/lib/inspection.test.js @@ -232,6 +232,36 @@ async function runTests() { 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' }) From 8f72869987ae54ef986380d5af9fbe46a1f6f279 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:01:56 +0200 Subject: [PATCH 28/69] fix: reject negative execution counters (cherry picked from commit fed9eebed5fc024c9316815e9767c2834fb31b3a) --- scripts/lib/skill-evolution/tracker.js | 4 ++-- tests/lib/skill-evolution.test.js | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) 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/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', () => { From 65f7dfb735163aa2369ce0a1ac4b99cc9ecbfe9c Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:28:33 +0200 Subject: [PATCH 29/69] fix: reject missing session database and limit values (cherry picked from commit 299899941dbc27a5b2e70b020860c9e6613ec8d8) --- scripts/sessions-cli.js | 6 ++ tests/scripts/sessions-cli.test.js | 97 ++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 tests/scripts/sessions-cli.test.js 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/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/); + }); +}); From f1c6692f995bf2e224a61844d8a2cd27c2096a01 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:15:12 +0200 Subject: [PATCH 30/69] fix: include the oldest dashboard window boundary (cherry picked from commit e32d1a4e4d8cebe79362868263dc7be9b718a6d2) --- scripts/lib/skill-evolution/dashboard.js | 2 +- tests/lib/skill-dashboard.test.js | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) 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/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'); From 5701a4f2e13681b0119689de9590f0d5ffe628ff Mon Sep 17 00:00:00 2001 From: Alan Vo <101240385+ALANDVO@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:14:19 +0000 Subject: [PATCH 31/69] fix: respect CLAUDE_CONFIG_DIR with WSL Windows-path conversion (cherry picked from commit aaacbc75924367887bcbe57721940acad1c59661) --- CHANGELOG.md | 1 + commands/auto-update.md | 2 +- commands/instinct-status.md | 2 +- commands/sessions.md | 12 +-- commands/skill-health.md | 6 +- commands/skill-host-compat.md | 4 +- hooks/codex-hooks.json | 2 +- hooks/hooks.json | 54 +++++----- hooks/hooks.metadata.json | 54 +++++----- scripts/lib/install-executor.js | 3 +- scripts/lib/install-targets/claude-home.js | 24 +++++ scripts/lib/install-targets/helpers.js | 49 +++++++++ scripts/lib/install/inventory.js | 6 +- scripts/lib/install/runtime.js | 1 + scripts/lib/resolve-ecc-root.js | 24 ++++- tests/lib/command-plugin-root.test.js | 39 ++++++++ tests/lib/install-targets.test.js | 109 +++++++++++++++++++++ tests/lib/resolve-ecc-root.test.js | 44 +++++++++ tests/scripts/install-apply.test.js | 45 +++++++++ 19 files changed, 409 insertions(+), 72 deletions(-) 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/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 { diff --git a/scripts/lib/install-targets/helpers.js b/scripts/lib/install-targets/helpers.js index 3ff268a46..f099b1239 100644 --- a/scripts/lib/install-targets/helpers.js +++ b/scripts/lib/install-targets/helpers.js @@ -7,6 +7,51 @@ 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]:[\\/]/; + +/** + * 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 +486,8 @@ module.exports = { isForeignPlatformPath, normalizeRelativePath, planClaudeHooksOperations, + isWSL, + resetWSLDetection, + setWSLForTesting, + convertWindowsPathToWSL, }; diff --git a/scripts/lib/install/inventory.js b/scripts/lib/install/inventory.js index dc861501d..8718fb70e 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. @@ -17,9 +18,12 @@ function resolveClaudePaths(options = {}) { || process.env.HOME || process.env.USERPROFILE || os.homedir(); - const configDir = options.configDir + const rawConfigDir = options.configDir || process.env.CLAUDE_CONFIG_DIR || path.join(homeDir, '.claude'); + // 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 configDir = convertWindowsPathToWSL(rawConfigDir); const projectRoot = options.projectRoot || process.cwd(); return { 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..4002593bc 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,7 +174,7 @@ 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 { + 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.strictEqual(checked, 1, 'Expected 1 codex-hooks.json resolver with CLAUDE_CONFIG_DIR'); +}); + console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index 68cedc557..a9ec9a95c 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -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 = '/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..f2b1e1482 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, '/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/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); From 67034687f3e56107a63a382a098ebeb9a4c0f55e Mon Sep 17 00:00:00 2001 From: Alan Vo <101240385+ALANDVO@users.noreply.github.com> Date: Sun, 18 Oct 2026 04:27:27 +0000 Subject: [PATCH 32/69] Fix CLAUDE_CONFIG_DIR tilde expansion, blank path fallback, and inline WSL resolver parity (cherry picked from commit 617ab29f8d64a113782c3494eb2ae6fda4687b3b) --- scripts/lib/install-targets/claude-home.js | 25 +++- scripts/lib/install-targets/helpers.js | 5 + scripts/lib/install/inventory.js | 46 +++++-- scripts/lib/resolve-ecc-root.js | 33 ++++- tests/lib/claude-config-tilde.test.js | 47 +++++++ tests/lib/command-plugin-root.test.js | 61 ++++++++- tests/lib/extracted-resolver-wsl.test.js | 139 +++++++++++++++++++++ tests/lib/inline-fallback-shell.test.js | 34 +++++ tests/lib/serialized-hook-wsl.test.js | 81 ++++++++++++ 9 files changed, 454 insertions(+), 17 deletions(-) create mode 100644 tests/lib/claude-config-tilde.test.js create mode 100644 tests/lib/extracted-resolver-wsl.test.js create mode 100644 tests/lib/inline-fallback-shell.test.js create mode 100644 tests/lib/serialized-hook-wsl.test.js diff --git a/scripts/lib/install-targets/claude-home.js b/scripts/lib/install-targets/claude-home.js index dfd90326e..c1a57c742 100644 --- a/scripts/lib/install-targets/claude-home.js +++ b/scripts/lib/install-targets/claude-home.js @@ -50,6 +50,20 @@ 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 @@ -61,12 +75,19 @@ function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) { 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); - return path.resolve(converted); + // 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(path.resolve(options.homeDir || os.homedir()), '.claude'); + return path.join(homeDir, '.claude'); } module.exports = createInstallTargetAdapter({ diff --git a/scripts/lib/install-targets/helpers.js b/scripts/lib/install-targets/helpers.js index f099b1239..61d17a767 100644 --- a/scripts/lib/install-targets/helpers.js +++ b/scripts/lib/install-targets/helpers.js @@ -36,6 +36,10 @@ function setWSLForTesting(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" @@ -490,4 +494,5 @@ module.exports = { resetWSLDetection, setWSLForTesting, convertWindowsPathToWSL, + INLINE_WSL_CONVERSION, }; diff --git a/scripts/lib/install/inventory.js b/scripts/lib/install/inventory.js index 8718fb70e..d38de69fa 100644 --- a/scripts/lib/install/inventory.js +++ b/scripts/lib/install/inventory.js @@ -13,22 +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 rawConfigDir = options.configDir - || process.env.CLAUDE_CONFIG_DIR - || path.join(homeDir, '.claude'); - // 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 configDir = convertWindowsPathToWSL(rawConfigDir); + 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/resolve-ecc-root.js b/scripts/lib/resolve-ecc-root.js index 4002593bc..157744f3a 100644 --- a/scripts/lib/resolve-ecc-root.js +++ b/scripts/lib/resolve-ecc-root.js @@ -174,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));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(_){}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 { + 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 24b5e03e2..31fa148fe 100644 --- a/tests/lib/command-plugin-root.test.js +++ b/tests/lib/command-plugin-root.test.js @@ -193,7 +193,13 @@ for (const level of ['slug', 'org']) { // 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 config = JSON.parse(fs.readFileSync(filePath, 'utf8')); + 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)) { @@ -222,10 +228,61 @@ test('hooks.json inline resolvers include WSL path conversion for CLAUDE_CONFIG_ 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.strictEqual(checked, 1, 'Expected 1 codex-hooks.json resolver with CLAUDE_CONFIG_DIR'); + // 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}`); process.exit(failed > 0 ? 1 : 0); + + 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/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; + +} From 0bdb6a52ab5e690979f839a4187311f32f7d9724 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:05:43 +0200 Subject: [PATCH 33/69] fix: validate session alias titles before saving (cherry picked from commit e4f9aac414995dd21ffa8ca2376aee6d0a63fb26) --- scripts/lib/session-aliases.js | 4 ++++ tests/lib/session-aliases.test.js | 32 ++++++++++++++++++------------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/scripts/lib/session-aliases.js b/scripts/lib/session-aliases.js index 5a7e47464..675e644af 100644 --- a/scripts/lib/session-aliases.js +++ b/scripts/lib/session-aliases.js @@ -215,6 +215,10 @@ function setAliasUnlocked(alias, sessionPath, title = null) { return { success: false, error: 'Session path cannot be empty' }; } + if (title !== null && typeof title !== 'string') { + return { success: false, error: 'Title must be a string or null' }; + } + if (alias.length > 128) { return { success: false, error: 'Alias name cannot exceed 128 characters' }; } diff --git a/tests/lib/session-aliases.test.js b/tests/lib/session-aliases.test.js index aaf31183a..d2b1c964e 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,21 +1300,14 @@ 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) ── From 21e0ab52716cbe69abb4097791860db3064daf0b Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:13:48 +0200 Subject: [PATCH 34/69] fix(session-aliases): reject array-shaped alias maps (cherry picked from commit b1668579f66579780a9d1de8a32c611a6f060ad3) --- scripts/lib/session-aliases.js | 2 +- tests/lib/session-aliases.test.js | 25 +++++++------------------ 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/scripts/lib/session-aliases.js b/scripts/lib/session-aliases.js index 675e644af..2e4e109d4 100644 --- a/scripts/lib/session-aliases.js +++ b/scripts/lib/session-aliases.js @@ -58,7 +58,7 @@ function loadAliases() { const data = JSON.parse(content); // Validate structure - if (!data.aliases || typeof data.aliases !== 'object') { + if (!data.aliases || typeof data.aliases !== 'object' || Array.isArray(data.aliases)) { log('[Aliases] Invalid aliases file structure, resetting'); return getDefaultAliases(); } diff --git a/tests/lib/session-aliases.test.js b/tests/lib/session-aliases.test.js index d2b1c964e..d1ade7036 100644 --- a/tests/lib/session-aliases.test.js +++ b/tests/lib/session-aliases.test.js @@ -1310,15 +1310,8 @@ function runTests() { 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({ @@ -1327,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) ── From 7b7ec11b2a575cc8d7e4069b2ce39b2cff25e92b Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:00:44 +0200 Subject: [PATCH 35/69] fix: preserve escaped instinct strings through serialization RED: multiline/control serialization and newline decoding failed; C1/NEL cases exposed malformed or folded YAML. GREEN: 40 targeted tests and 126 complete Linux tests pass; quote/parser coverage 100%/97.4%. Unicode, Windows paths and legacy escapes survive round trips; Pi/catalog checks pass. (cherry picked from commit 1b1119f4092259d0c733a98781432619d4ca8f55) --- .../scripts/instinct-cli.py | 12 ++++---- .../scripts/test_parse_instinct.py | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) 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..76733362b 100644 --- a/skills/continuous-learning-v2/scripts/test_parse_instinct.py +++ b/skills/continuous-learning-v2/scripts/test_parse_instinct.py @@ -281,6 +281,36 @@ def test_parse_confidence_preserves_valid_range(confidence): assert parse_instinct_file(content)[0]["confidence"] == confidence +@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 + + +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.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 + + +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 = """\ --- From 99b6a9bb8f878f151dab7d3f996a4abfe4e62cb6 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:05:24 +0200 Subject: [PATCH 36/69] fix: reject lossy classifier tool-call indices RED: 6 failures proved booleans and fractional indices could invent evidence. GREEN: 49 targeted tests; full skill-comply suites pass on Windows (88 passed, 5 platform skips) and Linux (93 passed). Parser coverage 100%; exact integer floats and numeric strings remain supported. Pi/catalog checks pass. (cherry picked from commit 6dde588860b440313c3cf63561f10203fcf28f55) --- skills/skill-comply/scripts/classifier.py | 2 ++ skills/skill-comply/tests/test_classifier.py | 9 +++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/skills/skill-comply/scripts/classifier.py b/skills/skill-comply/scripts/classifier.py index 76d7c87aa..74361c8bf 100644 --- a/skills/skill-comply/scripts/classifier.py +++ b/skills/skill-comply/scripts/classifier.py @@ -85,6 +85,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/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( From 429737572ac1e57e3708e6766abf21aac751fcab Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:14:27 +0200 Subject: [PATCH 37/69] fix: use UTF-8 throughout skill compliance workflows RED: seven Unicode regressions reproduced under Windows cp1252 and Linux ASCII with UTF-8 mode disabled. GREEN: Windows 91 passed with 5 platform skips; Linux ASCII 96 passed. Nine offline Unicode cases cover file, temporary YAML, prompt and all four CLI capture boundaries without model calls. Changed functions have 84.7% line coverage, each exceeds 80%; all 13 changed IO sites execute. Pi/catalog parity checks pass. (cherry picked from commit 642cb0d5126383dfbf71671f22d66d5ded247ade) --- skills/skill-comply/scripts/classifier.py | 4 +- skills/skill-comply/scripts/parser.py | 4 +- skills/skill-comply/scripts/run.py | 2 +- skills/skill-comply/scripts/runner.py | 2 +- .../scripts/scenario_generator.py | 6 +- skills/skill-comply/scripts/spec_generator.py | 8 +- skills/skill-comply/tests/test_utf8.py | 131 ++++++++++++++++++ 7 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 skills/skill-comply/tests/test_utf8.py diff --git a/skills/skill-comply/scripts/classifier.py b/skills/skill-comply/scripts/classifier.py index 74361c8bf..281792c67 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) @@ -50,7 +50,7 @@ def classify_events( result = subprocess.run( ["claude", "-p", prompt, "--model", model, "--output-format", "text"], capture_output=True, - text=True, + encoding="utf-8", timeout=60, ) 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..dd75b6a8f 100644 --- a/skills/skill-comply/scripts/runner.py +++ b/skills/skill-comply/scripts/runner.py @@ -59,7 +59,7 @@ def run_scenario( "--verbose", ], 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..aeb262b4b 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) @@ -43,7 +43,7 @@ def generate_scenarios( result = subprocess.run( ["claude", "-p", prompt, "--model", model, "--output-format", "text"], 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..8d99036ef 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 @@ -43,7 +43,7 @@ def generate_spec( result = subprocess.run( ["claude", "-p", prompt, "--model", model, "--output-format", "text"], capture_output=True, - text=True, + encoding="utf-8", timeout=120, ) @@ -54,7 +54,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_utf8.py b/skills/skill-comply/tests/test_utf8.py new file mode 100644 index 000000000..def3d5246 --- /dev/null +++ b/skills/skill-comply/tests/test_utf8.py @@ -0,0 +1,131 @@ +"""UTF-8 file and CLI boundaries must preserve Unicode under legacy locales.""" + +import json +import locale +import subprocess +import sys +from pathlib import Path +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 TEXT in command[2] + 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 command[2] + 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(command[2]) + 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 command[2] + 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 + + +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 From f78fe3287b3329489f331abd1406705c167870f7 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:11:08 +0200 Subject: [PATCH 38/69] test: enforce legacy locales for UTF-8 regressions (cherry picked from commit b5fa7e7fcfa051b2850249334c8046b3f8160482) --- skills/skill-comply/tests/test_utf8.py | 47 ++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/skills/skill-comply/tests/test_utf8.py b/skills/skill-comply/tests/test_utf8.py index def3d5246..af9c3064f 100644 --- a/skills/skill-comply/tests/test_utf8.py +++ b/skills/skill-comply/tests/test_utf8.py @@ -2,9 +2,11 @@ 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 @@ -129,3 +131,48 @@ def test_report_is_written_as_utf8(tmp_path: Path, monkeypatch: pytest.MonkeyPat 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 + + 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, mode="r", buffering=-1, encoding=None, *args, **kwargs): + 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 From e9176a14e355ab7796a62e6f269b794bf468fea1 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:21:59 +0200 Subject: [PATCH 39/69] test: annotate the legacy locale I/O wrapper (cherry picked from commit c5704c128ea25f7c128bbc7f3d22e63db8cbade0) --- skills/skill-comply/tests/test_utf8.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/skills/skill-comply/tests/test_utf8.py b/skills/skill-comply/tests/test_utf8.py index af9c3064f..23142ab45 100644 --- a/skills/skill-comply/tests/test_utf8.py +++ b/skills/skill-comply/tests/test_utf8.py @@ -142,12 +142,16 @@ def test_utf8_boundaries_under_legacy_encoding() -> None: 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, mode="r", buffering=-1, encoding=None, *args, **kwargs): + 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) From 1d20bed37f70517ac9775cc7536fb59b9f58c0a8 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:18:01 +0200 Subject: [PATCH 40/69] fix: treat optional-only specs as compliant (cherry picked from commit ed6799f33ca609a15b345b40537b5e179ea4e998) --- skills/skill-comply/scripts/grader.py | 2 +- skills/skill-comply/tests/test_grader.py | 43 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) 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/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, []) From accdc30c07982934648b83eb21d306dc0184702e Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:41:21 -0400 Subject: [PATCH 41/69] ci: refresh registry digest for combined skill changes --- manifests/context-packs/skill-triggers@1.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index 62df0662b..79e153848 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "eb958f9f43b73851550a2142dc5c5da693bba33bf1c8267db3d9722cee4b891b", + "registryDigest": "667d135b2949840589e10a23270cef1e6cce53cb1026e6ed82d08ab88ed470f3", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [ From 45a021ea110e9c581597f1181a49351a2faa5091 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:41:33 -0400 Subject: [PATCH 42/69] test: remove extra trailing blank line from installer coverage --- tests/lib/command-plugin-root.test.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/lib/command-plugin-root.test.js b/tests/lib/command-plugin-root.test.js index 31fa148fe..e9b8f93e3 100644 --- a/tests/lib/command-plugin-root.test.js +++ b/tests/lib/command-plugin-root.test.js @@ -284,5 +284,3 @@ console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); - - From c5378bf45676d624d6f49ceb0505f5730bee273c Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 14:37:06 -0300 Subject: [PATCH 43/69] docs(fastapi-patterns): import Depends in the router example The users router uses Depends() for OAuth2PasswordRequestForm but the import line omits it, so the module raises NameError on import. Regenerated pi/core with scripts/build-pi-core.js. Signed-off-by: Wen (cherry picked from commit d383fb57e9b60fa2255346b3bb24dbf560a36ac4) --- pi/core/skills/fastapi-patterns/SKILL.md | 2 +- skills/fastapi-patterns/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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 From 580acac9264c6669fbd6aed926ca2184d0214f05 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 14:37:06 -0300 Subject: [PATCH 44/69] docs(laravel-security): drop the undefined sanitize_html validation rule sanitize_html is not a built-in Laravel rule and the skill never registers it, so the validator throws BadMethodCallException. The same FormRequest already strips tags in validated(). Regenerated pi/core. Signed-off-by: Wen (cherry picked from commit 453c0651f7fc1856159d601b412132eb36e506a5) --- pi/core/skills/laravel-security/SKILL.md | 2 +- skills/laravel-security/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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', From a492b081264a435736dde519ff932d222e09fa9e Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 14:37:06 -0300 Subject: [PATCH 45/69] docs(perl-security): drop nonexistent perlcritic policies Perl::Critic has no InputOutput::RequireThreeArgOpen or Modules::RequireTaintChecking policy. perlcritic warns that they are not installed and ignores them. Two-arg open is already covered by InputOutput::ProhibitTwoArgOpen in the same profile. Regenerated pi/core. Signed-off-by: Wen (cherry picked from commit d70726d79c5b4fd6d4a5f3cedb5fa2d0af2f1850) --- pi/core/skills/perl-security/SKILL.md | 8 -------- skills/perl-security/SKILL.md | 8 -------- 2 files changed, 16 deletions(-) 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/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 From 95e6e4e7358cb71ba90f7e7555a3f571a27e5f4f Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 14:37:06 -0300 Subject: [PATCH 46/69] docs(django-security): keep the CSRF cookie readable for the AJAX example The AJAX example reads csrftoken from document.cookie, which fails when CSRF_COOKIE_HTTPONLY is True. Drop the setting from the production block (the default is False) and set it to False in the CSRF block, as the .kiro copy already does there. Also remove SECURE_BROWSER_XSS_FILTER, which Django 4.0 removed. Updated .kiro and regenerated pi/core. Signed-off-by: Wen (cherry picked from commit bdc4407edffa986896964599e982c6c33fc20271) --- .kiro/skills/django-security/SKILL.md | 3 --- pi/core/skills/django-security/SKILL.md | 5 +---- skills/django-security/SKILL.md | 5 +---- 3 files changed, 2 insertions(+), 11 deletions(-) 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/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/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 From 73e92ae9940ac70e1286761476b0c2b62a4aa711 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 14:37:06 -0300 Subject: [PATCH 47/69] docs(safety-guard): state that the skill is advisory and ships no hook ECC ships no /safety-guard command, no PreToolUse hook for it and nothing that writes ~/.claude/safety-guard.log. Describe the modes as rules the agent follows and point to permission deny rules or hooks for enforced blocking. Signed-off-by: Wen (cherry picked from commit b23d73b18244c3bebe2cd1605b8bb3f14ca61360) --- skills/safety-guard/SKILL.md | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) 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` From daa22ec2ead0dbcc191d2944baf21725d1410747 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 14:37:06 -0300 Subject: [PATCH 48/69] docs(social-publisher): require user approval before socialclaw apply apply creates real posts, but the workflow went straight from validate to apply. Require showing every post and getting explicit approval first. Also note that the CLI only takes the API key as an argument, so the user should run login themselves instead of the agent. Signed-off-by: Wen (cherry picked from commit 93dce707f1f8f5bd86761b96d361b2bea359d777) --- skills/social-publisher/SKILL.md | 5 +++++ 1 file changed, 5 insertions(+) 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": "..." } From 936cf3d040c82a01d1dbd3b70d59079c30f23e09 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 13:54:57 -0300 Subject: [PATCH 49/69] fix(delivery-gate): honor stop_hook_active and match Claude Code project dir encoding quality-gate.py never read stop_hook_active, so a condition the agent cannot fix (disk below 15 GB, an unwritable growth-log) blocked every Stop and kept the session generating turns. Exit 0 when Claude Code reports that the Stop hook already forced a continuation. The memory dir lookup only mapped '/', '\' and ':' to '-'. Claude Code maps every non-alphanumeric character, so for any project path with a space the memory dir was never found and enforcement silently became warn-only. Align SKILL.md with the code: disk thresholds (remind 50 / warn 30 / block 15), the disk block applies to every session, the learning-lib rule and its examples, stderr messages, and the hook timeout, which Claude Code reads in seconds (5000 meant 83 minutes). Signed-off-by: Wen (cherry picked from commit a1385c439701829f3ece9aae5b5328f4566ed61b) --- skills/delivery-gate/SKILL.md | 26 ++-- skills/delivery-gate/hooks/quality-gate.py | 7 +- .../skills/delivery-gate-quality-gate.test.js | 131 ++++++++++++++++++ 3 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 tests/skills/delivery-gate-quality-gate.test.js diff --git a/skills/delivery-gate/SKILL.md b/skills/delivery-gate/SKILL.md index 940e26160..e4dff3963 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. + +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. 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/tests/skills/delivery-gate-quality-gate.test.js b/tests/skills/delivery-gate-quality-gate.test.js new file mode 100644 index 000000000..ed55124e6 --- /dev/null +++ b/tests/skills/delivery-gate-quality-gate.test.js @@ -0,0 +1,131 @@ +/** + * 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); + + for (const candidate of candidates) { + const result = spawnSync(candidate.command, [...candidate.args, '--version'], { + 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(); From 6839f58cd350fa20922b34c15e260731b19a731a Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 16:40:36 -0300 Subject: [PATCH 50/69] test(delivery-gate): select an interpreter only if it runs Python 3 Signed-off-by: Wen (cherry picked from commit fe861f8983ce732f34f3389b031030989f37d5e2) --- tests/skills/delivery-gate-quality-gate.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/skills/delivery-gate-quality-gate.test.js b/tests/skills/delivery-gate-quality-gate.test.js index ed55124e6..106881775 100644 --- a/tests/skills/delivery-gate-quality-gate.test.js +++ b/tests/skills/delivery-gate-quality-gate.test.js @@ -22,8 +22,10 @@ function findPython() { { 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, '--version'], { + const result = spawnSync(candidate.command, [...candidate.args, '-c', 'import sys; sys.exit(sys.version_info[0] != 3)'], { encoding: 'utf8', timeout: 5000, }); From 2eed06b585dc9fc023b90fceee81b345f6cd41b3 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 18:12:58 -0300 Subject: [PATCH 51/69] docs(delivery-gate): document that a missing memory directory only warns With no project memory directory the hook cannot check the learning libraries, so a complex task is warned, not blocked. Say so in the blocking-conditions paragraph and in Limitations, as CodeRabbit pointed out on #3575. Disk checks still apply. Signed-off-by: Wen (cherry picked from commit 353cfa2b570df46df70f2fb763be7b75a4c7f7b6) --- skills/delivery-gate/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/delivery-gate/SKILL.md b/skills/delivery-gate/SKILL.md index e4dff3963..bfd4e2542 100644 --- a/skills/delivery-gate/SKILL.md +++ b/skills/delivery-gate/SKILL.md @@ -26,7 +26,7 @@ This is the same pattern as CI pipeline gates — automated, deterministic check | 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 (any session), or, on a complex task (>=3 Edit/Write calls), `>=3 learning libs stale` OR `growth-log` specifically stale. +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. @@ -114,7 +114,7 @@ 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. 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. 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 From cf3408097c91dd07440cead64799c46dadec7546 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 14:01:07 -0300 Subject: [PATCH 52/69] fix(ck): run commands from the skill directory and pass JSON through a heredoc - SKILL.md invoked every script through ~/.claude/skills/ck, which does not exist when ck is installed as part of the ECC plugin, so every /ck:* command failed with MODULE_NOT_FOUND. Use ${CLAUDE_SKILL_DIR} and tell non-Claude hosts to substitute the skill directory. Scripts still run from the project directory, since they use it as the project path. - The documented SessionStart command quoted "~/...", which the shell never expands. Use $HOME and explain the plugin-install case. - /ck:init and /ck:save piped JSON through echo ''. Any apostrophe ends the quoted string, and init JSON carries README/package.json text. Pass it through a quoted heredoc instead. - session-start.mjs and shared.mjs resolve SKILL.md next to the code (import.meta.url), and the hook expands ${CLAUDE_SKILL_DIR} in the text it injects. - encodeProjectPath only replaced '/', while Claude Code maps every non-alphanumeric character to '-'. Memory entries for projects whose path has a space or dot went to a directory Claude Code never reads. Signed-off-by: Wen (cherry picked from commit c920005c2baa17e2fdf26fe22146a25e3a520405) --- skills/ck/SKILL.md | 47 ++++++++---- skills/ck/commands/shared.mjs | 10 ++- skills/ck/hooks/session-start.mjs | 12 ++- tests/skills/ck-paths.test.js | 119 ++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 22 deletions(-) create mode 100644 tests/skills/ck-paths.test.js 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..0ef9cd2fa 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,10 @@ 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. + const skill = existsSync(SKILL_FILE) + ? readFileSync(SKILL_FILE, 'utf8').replaceAll('${CLAUDE_SKILL_DIR}', SKILL_DIR) + : ''; const projects = readJson(PROJECTS_FILE) || {}; const entry = projects[cwd]; diff --git a/tests/skills/ck-paths.test.js b/tests/skills/ck-paths.test.js new file mode 100644 index 000000000..f9685e879 --- /dev/null +++ b/tests/skills/ck-paths.test.js @@ -0,0 +1,119 @@ +#!/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'); + assert.ok(additionalContext.includes(path.join(SKILL_DIR, 'commands')), 'skill dir not substituted'); + } 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(); From 59868a9404b840ad974ce190c7b72cc77493e51f Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 16:42:17 -0300 Subject: [PATCH 53/69] fix(ck): escape the skill dir for bash when SessionStart fills it in Signed-off-by: Wen (cherry picked from commit 00a03ab84fc7960143ecf7568cdf975e15b3de37) --- skills/ck/hooks/session-start.mjs | 5 ++++- tests/skills/ck-paths.test.js | 26 +++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/skills/ck/hooks/session-start.mjs b/skills/ck/hooks/session-start.mjs index 0ef9cd2fa..47d3623b7 100644 --- a/skills/ck/hooks/session-start.mjs +++ b/skills/ck/hooks/session-start.mjs @@ -89,8 +89,11 @@ function main() { // Load skill (always inject — now only ~50 lines) // 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}', SKILL_DIR) + ? readFileSync(SKILL_FILE, 'utf8').replaceAll('${CLAUDE_SKILL_DIR}', () => shellSkillDir) : ''; const projects = readJson(PROJECTS_FILE) || {}; diff --git a/tests/skills/ck-paths.test.js b/tests/skills/ck-paths.test.js index f9685e879..9ab5d88d4 100644 --- a/tests/skills/ck-paths.test.js +++ b/tests/skills/ck-paths.test.js @@ -78,7 +78,31 @@ async function runTests() { 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'); - assert.ok(additionalContext.includes(path.join(SKILL_DIR, 'commands')), 'skill dir not substituted'); + // Escaped for double-quoted bash (Windows backslashes are doubled). + const shellCommandsDir = path.join(SKILL_DIR, 'commands').replace(/[\\$`"]/g, '\\$&'); + 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 }); } From ea8b48860e65544d95bf5b41a9910e6d664cf40a Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 14:04:26 -0300 Subject: [PATCH 54/69] fix(frontend-slides): make relative PDF output paths absolute before export The "resolve output to absolute path" step only rebuilt "$OUTPUT_DIR/$(basename ...)", so ./slides.pdf stayed relative. The script then cd's into a temp dir, Playwright writes the PDF there, and the temp dir is deleted. The script prints "PDF exported successfully!" and then fails on du under set -e, with no PDF produced. This is the script's own documented example. Resolve the output directory with cd + pwd, as the input path already does, and add a stubbed regression test (no network or browser). Signed-off-by: Wen (cherry picked from commit f91c14749e7f5f7baac0bdb5b74bad79581ec634) --- skills/frontend-slides/scripts/export-pdf.sh | 4 +- .../skills/frontend-slides-export-pdf.test.js | 77 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 tests/skills/frontend-slides-export-pdf.test.js diff --git a/skills/frontend-slides/scripts/export-pdf.sh b/skills/frontend-slides/scripts/export-pdf.sh index 2ab289f89..717a0900e 100755 --- a/skills/frontend-slides/scripts/export-pdf.sh +++ b/skills/frontend-slides/scripts/export-pdf.sh @@ -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="$(cd "$OUTPUT_DIR" && pwd)/$(basename "$OUTPUT_PDF")" echo "" echo -e "${BOLD}========================================${NC}" 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..e38490315 --- /dev/null +++ b/tests/skills/frontend-slides-export-pdf.test.js @@ -0,0 +1,77 @@ +#!/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 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', () => { + 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(work); + // 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 writes the PDF to the output path argument, resolved against its cwd. + fs.writeFileSync(path.join(bin, 'node'), '#!/bin/sh\nprintf "%%PDF-stub" > "$4"\n', { mode: 0o755 }); + fs.writeFileSync(path.join(work, 'presentation.html'), '\n'); + + const result = spawnSync('bash', [SCRIPT, './presentation.html', './slides.pdf'], { + cwd: work, + encoding: 'utf8', + env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}` }, + }); + + 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'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exitCode = failed > 0 ? 1 : 0; +} + +runTests(); From f933bad11baaad43521fac90c60dfc5277913744 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 16:43:14 -0300 Subject: [PATCH 55/69] fix(frontend-slides): ignore CDPATH when resolving input and output paths Signed-off-by: Wen (cherry picked from commit 6b53bedc747d197c7eaf982d93d25679c166348a) --- skills/frontend-slides/scripts/export-pdf.sh | 4 +- .../skills/frontend-slides-export-pdf.test.js | 59 +++++++++++-------- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/skills/frontend-slides/scripts/export-pdf.sh b/skills/frontend-slides/scripts/export-pdf.sh index 717a0900e..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 @@ -85,7 +85,7 @@ fi # 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="$(cd "$OUTPUT_DIR" && pwd)/$(basename "$OUTPUT_PDF")" +OUTPUT_PDF="$(CDPATH= cd -- "$OUTPUT_DIR" && pwd)/$(basename "$OUTPUT_PDF")" echo "" echo -e "${BOLD}========================================${NC}" diff --git a/tests/skills/frontend-slides-export-pdf.test.js b/tests/skills/frontend-slides-export-pdf.test.js index e38490315..5ce0bb7b4 100644 --- a/tests/skills/frontend-slides-export-pdf.test.js +++ b/tests/skills/frontend-slides-export-pdf.test.js @@ -28,6 +28,31 @@ function test(name, fn) { } } +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'); @@ -42,31 +67,19 @@ function runTests() { let failed = 0; if (test('writes a relative output path next to the caller, not into the deleted temp dir', () => { - 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(work); - // 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 writes the PDF to the output path argument, resolved against its cwd. - fs.writeFileSync(path.join(bin, 'node'), '#!/bin/sh\nprintf "%%PDF-stub" > "$4"\n', { mode: 0o755 }); - fs.writeFileSync(path.join(work, 'presentation.html'), '\n'); - - const result = spawnSync('bash', [SCRIPT, './presentation.html', './slides.pdf'], { - cwd: work, - encoding: 'utf8', - env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}` }, - }); - + 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'); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } + }); + })) 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}`); From 3906ba98f3ccc2cd2caa420a7cf11c3fb60bd09f Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 14:31:49 -0300 Subject: [PATCH 56/69] fix(opensource-pipeline): commit packaging and sanitizer fixes before publishing The forker makes the only commit before the sanitizer and packager run, and Step 7 publishes with `gh repo create --source=. --push`, which sends commits, not the working tree. Packager output (LICENSE, README, CLAUDE.md, setup.sh) never reached the public repo, and a secret the sanitizer caught and the agent fixed in the working tree was still pushed in that commit. - Fold sanitizer fixes and packager output into the single commit with `git commit --amend`, keeping FORK_REPORT.md and SANITIZATION_REPORT.md out through .git/info/exclude. - Re-run the sanitizer after packaging (Step 5b) and refuse to push with uncommitted changes or more than one commit. - Sanitizer Step 6 now runs the Step 1 secret patterns and Step 3 home path patterns over `git log -p --all`, not only a 4-keyword grep. Signed-off-by: Wen (cherry picked from commit fe767e2099ca09dcd4d8928e5d74bf913c0f28b5) --- agents/opensource-forker.md | 5 ++++ agents/opensource-sanitizer.md | 14 +++++++++-- skills/opensource-pipeline/SKILL.md | 36 ++++++++++++++++++++++++++--- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/agents/opensource-forker.md b/agents/opensource-forker.md index 4c5d8cbb8..ede323d1a 100644 --- a/agents/opensource-forker.md +++ b/agents/opensource-forker.md @@ -150,8 +150,13 @@ 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." +printf '%s\n' FORK_REPORT.md SANITIZATION_REPORT.md >> .git/info/exclude ``` +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` keeps them out of `git add -A`. + ### 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/skills/opensource-pipeline/SKILL.md b/skills/opensource-pipeline/SKILL.md index d72f15eb0..7d8e4c5a9 100644 --- a/skills/opensource-pipeline/SKILL.md +++ b/skills/opensource-pipeline/SKILL.md @@ -82,6 +82,23 @@ 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 commit --amend --no-edit +git status --porcelain # 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 +130,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 +185,13 @@ 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. + #### Step 6: Final Review Present to user: @@ -184,7 +208,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 +220,13 @@ 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 changed since Step 5b (for example the user +edited files after "review first"), go back to Step 5b instead of pushing. + ```bash cd "{STAGING_PATH}" +test -z "$(git status --porcelain)" && test "$(git rev-list --count HEAD)" = 1 \ + || { echo "Uncommitted changes or extra commits: return to Step 5b" >&2; exit 1; } gh repo create "{github_org}/{github_repo}" --public --source=. --push --description "{description}" ``` @@ -270,13 +299,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 From 1acdd02fb4b5a662530b88db6020278ed9a83646 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 16:44:53 -0300 Subject: [PATCH 57/69] fix(opensource-pipeline): publish only the commit the sanitizer scanned Signed-off-by: Wen (cherry picked from commit 21d914fa77166827b9972d733c78eafdff376b4c) --- skills/opensource-pipeline/SKILL.md | 14 ++- .../opensource-pipeline-publish-guard.test.js | 114 ++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 tests/skills/opensource-pipeline-publish-guard.test.js diff --git a/skills/opensource-pipeline/SKILL.md b/skills/opensource-pipeline/SKILL.md index 7d8e4c5a9..d0f03397c 100644 --- a/skills/opensource-pipeline/SKILL.md +++ b/skills/opensource-pipeline/SKILL.md @@ -192,6 +192,12 @@ sanitizer again exactly as in Step 4, against what will be published. Apply the 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: @@ -220,13 +226,15 @@ 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 changed since Step 5b (for example the user -edited files after "review first"), go back to Step 5b instead of pushing. +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. ```bash cd "{STAGING_PATH}" test -z "$(git status --porcelain)" && test "$(git rev-list --count HEAD)" = 1 \ - || { echo "Uncommitted changes or extra commits: return to Step 5b" >&2; exit 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}" ``` 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..a91e46b79 --- /dev/null +++ b/tests/skills/opensource-pipeline-publish-guard.test.js @@ -0,0 +1,114 @@ +#!/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) { + 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'); + 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++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exitCode = failed > 0 ? 1 : 0; +} + +runTests(); From feff29fd80d48370d0f74befe17bc752d601ea2d Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 17 Oct 2026 09:42:28 -0300 Subject: [PATCH 58/69] fix(opensource-pipeline): keep pre-existing reports out of the first fork commit Signed-off-by: Wen (cherry picked from commit 35909e548e0445ed0536b7c2648507b858c64cd7) --- agents/opensource-forker.md | 5 +- .../skills/opensource-forker-history.test.js | 48 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 tests/skills/opensource-forker-history.test.js diff --git a/agents/opensource-forker.md b/agents/opensource-forker.md index ede323d1a..10628b73d 100644 --- a/agents/opensource-forker.md +++ b/agents/opensource-forker.md @@ -145,17 +145,18 @@ 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 commit -m "Initial open-source release Forked from private source. All secrets stripped, internal references replaced with configurable placeholders. See .env.example for configuration." -printf '%s\n' FORK_REPORT.md SANITIZATION_REPORT.md >> .git/info/exclude ``` 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` keeps them out of `git add -A`. +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. ### Step 7: Generate Fork Report diff --git a/tests/skills/opensource-forker-history.test.js b/tests/skills/opensource-forker-history.test.js new file mode 100644 index 000000000..8c75c2c3e --- /dev/null +++ b/tests/skills/opensource-forker-history.test.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/** + * Runs the documented Step 6 of agents/opensource-forker.md in a throwaway dir that already + * holds FORK_REPORT.md / SANITIZATION_REPORT.md (as if the source project shipped them), and + * checks they stay out of the first 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 AGENT_MD = path.join(__dirname, '..', '..', 'agents', 'opensource-forker.md'); + +const step = fs.readFileSync(AGENT_MD, 'utf8').split('### Step 6: Clean Git History')[1]; +assert.ok(step, 'opensource-forker.md is missing "### Step 6: Clean Git History"'); +const block = step.match(/```bash\n([\s\S]*?)```/); +assert.ok(block, 'no bash block in Step 6'); + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oss-forker-')); +try { + for (const f of ['README.md', 'FORK_REPORT.md', 'SANITIZATION_REPORT.md']) { + fs.writeFileSync(path.join(dir, f), 'x\n'); + } + const r = spawnSync('bash', ['-ec', block[1].replaceAll('TARGET_DIR', 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); + const files = spawnSync('git', ['ls-tree', '-r', '--name-only', 'HEAD'], { cwd: dir, encoding: 'utf8' }).stdout.trim(); + assert.strictEqual(files, 'README.md', `first commit must hold only README.md, got: ${files}`); + console.log(' ✓ pre-existing reports stay out of the first fork commit'); +} catch (err) { + console.log(' ✗ pre-existing reports stay out of the first fork commit'); + console.log(` Error: ${err.message}`); + process.exitCode = 1; +} finally { + fs.rmSync(dir, { recursive: true, force: true }); +} From 79d22134e6d98d6fdc820664507977a5e499b875 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 19 Oct 2026 07:23:00 -0300 Subject: [PATCH 59/69] fix(opensource-pipeline): drop copied reports from the index even when .gitignore re-includes them A .gitignore rule outranks .git/info/exclude, so a source project that ships !FORK_REPORT.md made git add -A stage the report. Remove both reports from the index after staging, in the forker's first commit and in every fold-in amend. The history test now runs both documented blocks against a negating .gitignore, prints the Passed/Failed summary for run-all.js, and skips on Windows like the publish-guard test. (cherry picked from commit f493d9ff668014d88177871a880b0f3cf473b0a7) --- agents/opensource-forker.md | 5 +- skills/opensource-pipeline/SKILL.md | 3 +- .../skills/opensource-forker-history.test.js | 70 ++++++++++++++----- 3 files changed, 58 insertions(+), 20 deletions(-) diff --git a/agents/opensource-forker.md b/agents/opensource-forker.md index 10628b73d..b2c07103b 100644 --- a/agents/opensource-forker.md +++ b/agents/opensource-forker.md @@ -147,6 +147,7 @@ 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 @@ -156,7 +157,9 @@ 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. +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 diff --git a/skills/opensource-pipeline/SKILL.md b/skills/opensource-pipeline/SKILL.md index d0f03397c..47dd03644 100644 --- a/skills/opensource-pipeline/SKILL.md +++ b/skills/opensource-pipeline/SKILL.md @@ -91,8 +91,9 @@ sanitizer passed. The reports stay out of it. Fold changes in with: 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 # must print nothing +git status --porcelain -uno # must print nothing git rev-list --count HEAD # must print 1 ``` diff --git a/tests/skills/opensource-forker-history.test.js b/tests/skills/opensource-forker-history.test.js index 8c75c2c3e..5339309d9 100644 --- a/tests/skills/opensource-forker-history.test.js +++ b/tests/skills/opensource-forker-history.test.js @@ -1,8 +1,9 @@ #!/usr/bin/env node /** - * Runs the documented Step 6 of agents/opensource-forker.md in a throwaway dir that already - * holds FORK_REPORT.md / SANITIZATION_REPORT.md (as if the source project shipped them), and - * checks they stay out of the first commit. + * 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 */ @@ -15,19 +16,20 @@ const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); -const AGENT_MD = path.join(__dirname, '..', '..', 'agents', 'opensource-forker.md'); +const ROOT = path.join(__dirname, '..', '..'); +const NAME = 'pre-existing reports stay out of the published fork commit'; -const step = fs.readFileSync(AGENT_MD, 'utf8').split('### Step 6: Clean Git History')[1]; -assert.ok(step, 'opensource-forker.md is missing "### Step 6: Clean Git History"'); -const block = step.match(/```bash\n([\s\S]*?)```/); -assert.ok(block, 'no bash block in Step 6'); +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]; +} -const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oss-forker-')); -try { - for (const f of ['README.md', 'FORK_REPORT.md', 'SANITIZATION_REPORT.md']) { - fs.writeFileSync(path.join(dir, f), 'x\n'); - } - const r = spawnSync('bash', ['-ec', block[1].replaceAll('TARGET_DIR', dir)], { +function run(script, dir) { + const r = spawnSync('bash', ['-ec', script], { + cwd: dir, encoding: 'utf8', env: { ...process.env, @@ -36,13 +38,45 @@ try { }, }); 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 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, 'README.md', `first commit must hold only README.md, got: ${files}`); - console.log(' ✓ pre-existing reports stay out of the first fork commit'); + assert.strictEqual(files, '.gitignore\nCLAUDE.md\nREADME.md', `commit must not hold the reports, got: ${files}`); + console.log(` ✓ ${NAME}`); } catch (err) { - console.log(' ✗ pre-existing reports stay out of the first fork commit'); + failed = 1; + console.log(` ✗ ${NAME}`); console.log(` Error: ${err.message}`); - process.exitCode = 1; } finally { fs.rmSync(dir, { recursive: true, force: true }); } + +console.log(`\nPassed: ${1 - failed}`); +console.log(`Failed: ${failed}`); +process.exitCode = failed; From d9ad010b19120d338ac374ef4c18940e692111b6 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:01:48 -0400 Subject: [PATCH 60/69] ci: refresh digest for ten combined skill fixes --- manifests/context-packs/skill-triggers@1.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index 79e153848..f08000c81 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "667d135b2949840589e10a23270cef1e6cce53cb1026e6ed82d08ab88ed470f3", + "registryDigest": "048d14863299b0bf320c413b6359dc918fca2dda8d5db86cd677611cb28e2958", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [ From 90b9fba1a41111185bece4b09227297be23e7ea5 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:17:37 -0400 Subject: [PATCH 61/69] fix(opencode): pin Node 18 compatible runtime plugin --- package-lock.json | 296 ++--------------------------------- package.json | 2 +- yarn.lock | 388 ++-------------------------------------------- 3 files changed, 27 insertions(+), 659 deletions(-) diff --git a/package-lock.json b/package-lock.json index bb4dcbd04..cd840aa01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@iarna/toml": "2.2.5", - "@opencode-ai/plugin": "1.18.34", + "@opencode-ai/plugin": "1.4.3", "ajv": "8.20.0", "js-yaml": "4.3.2", "sql.js": "1.14.2" @@ -36,18 +36,6 @@ "node": ">=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==", - "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", @@ -336,127 +324,37 @@ "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" - ], - "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" - ], - "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" - ], - "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" - ], - "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" - ], - "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" - ], - "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==", "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==", "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==", - "license": "MIT" - }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -885,16 +783,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==", - "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", @@ -909,24 +797,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==", - "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", @@ -1148,28 +1018,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==", - "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", @@ -1234,12 +1082,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==", - "license": "MIT" - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1421,6 +1263,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "dev": true, "license": "ISC", "engines": { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" @@ -1574,12 +1417,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==", - "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", @@ -1647,12 +1484,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==", - "license": "Apache-2.0" - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2403,43 +2234,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==", - "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==", - "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==", - "license": "MIT" - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2447,21 +2241,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==", - "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", @@ -2610,22 +2389,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==", - "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", @@ -2838,15 +2601,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==", - "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", @@ -2898,19 +2652,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==", - "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", @@ -2961,21 +2702,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==", - "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", diff --git a/package.json b/package.json index 10f5641f3..4231df3d2 100644 --- a/package.json +++ b/package.json @@ -543,7 +543,7 @@ }, "dependencies": { "@iarna/toml": "2.2.5", - "@opencode-ai/plugin": "1.18.34", + "@opencode-ai/plugin": "1.4.3", "ajv": "8.20.0", "js-yaml": "4.3.2", "sql.js": "1.14.2" diff --git a/yarn.lock b/yarn.lock index 032184e3b..a191cf3f2 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" @@ -674,24 +581,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 +595,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 +723,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" @@ -906,13 +772,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 +851,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 +909,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 +977,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 +1030,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 +1071,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 +1497,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 +1511,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 +1518,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 +1603,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 +1617,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 +1654,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 +1755,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 +1766,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 +1776,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 +1819,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 +1828,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 +1850,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 +1875,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" From c04d00c15e285b1e6422dd432da5355a5da2d005 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:17:37 -0400 Subject: [PATCH 62/69] fix(integration): address review findings in docs and diagnostics --- manifests/context-packs/skill-triggers@1.json | 2 +- pi/core/skills/python-testing/SKILL.md | 11 ++++++----- scripts/lib/inspection.js | 5 +++-- skills/python-testing/SKILL.md | 11 ++++++----- tests/commands/santa-loop.test.js | 2 ++ tests/lib/inspection.test.js | 13 +++++++++++++ 6 files changed, 31 insertions(+), 13 deletions(-) diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index 62df0662b..1b8035e53 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "eb958f9f43b73851550a2142dc5c5da693bba33bf1c8267db3d9722cee4b891b", + "registryDigest": "1cbe4074364e551b4cc250b18e476b041e4829e95238fabfc1459a9c2fb563da", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [ diff --git a/pi/core/skills/python-testing/SKILL.md b/pi/core/skills/python-testing/SKILL.md index 713aeb429..8e68a084a 100644 --- a/pi/core/skills/python-testing/SKILL.md +++ b/pi/core/skills/python-testing/SKILL.md @@ -486,20 +486,21 @@ async def test_async_with_fixture(async_client): ### Async Fixture (FastAPI) -`ASGITransport` does not run lifespan events. If the app needs startup/shutdown, wrap the client block in `async with LifespanManager(app) as manager:` (from `asgi_lifespan`) -and use `ASGITransport(app=manager.app)`. See [FastAPI's async testing guidance](https://github.com/fastapi/fastapi/blob/master/docs/en/docs/advanced/async-tests.md). +`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 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.""" + """Async fixture providing async test client with lifespan events.""" app = create_app() # Returns a FastAPI application - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - yield client + 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): diff --git a/scripts/lib/inspection.js b/scripts/lib/inspection.js index 43ed7046e..1e24df064 100644 --- a/scripts/lib/inspection.js +++ b/scripts/lib/inspection.js @@ -25,8 +25,9 @@ function normalizeFailureReason(reason) { // 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. - // Spaced leaves need an extension before a colon, or quotes; preserve extensionless diagnostics. - .replace(/(?:\b[a-z]:|\\)[\\/](?:[^\\/:*?"<>|\r\n]*[^\s\\/:*?"<>|][\\/])*[^\s\\/:*?"<>|(),;]+(?: [^\\/:*?"<>|\r\n]*\.[^\s\\/:*?"<>|]+(?=:))?[\\/]*/gu, '') + // 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\\/:*?"<>|(),;]+(?: (?!failed\b|timed\b|could\b|cannot\b|unable\b|error\b)[^\s\\/:*?"<>|]+\.[^\s\\/:*?"<>|]+(?=:))?[\\/]*/gu, '') // Strip remaining file paths .replace(/(?:\b[a-z]:)?[\\/][\w./\\-]+/g, '') // Collapse whitespace diff --git a/skills/python-testing/SKILL.md b/skills/python-testing/SKILL.md index 713aeb429..8e68a084a 100644 --- a/skills/python-testing/SKILL.md +++ b/skills/python-testing/SKILL.md @@ -486,20 +486,21 @@ async def test_async_with_fixture(async_client): ### Async Fixture (FastAPI) -`ASGITransport` does not run lifespan events. If the app needs startup/shutdown, wrap the client block in `async with LifespanManager(app) as manager:` (from `asgi_lifespan`) -and use `ASGITransport(app=manager.app)`. See [FastAPI's async testing guidance](https://github.com/fastapi/fastapi/blob/master/docs/en/docs/advanced/async-tests.md). +`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 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.""" + """Async fixture providing async test client with lifespan events.""" app = create_app() # Returns a FastAPI application - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - yield client + 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): diff --git a/tests/commands/santa-loop.test.js b/tests/commands/santa-loop.test.js index a1ae5a2d2..69248e18f 100644 --- a/tests/commands/santa-loop.test.js +++ b/tests/commands/santa-loop.test.js @@ -15,6 +15,8 @@ const commands = [ if (process.platform === 'win32') { console.log('Skipped: Santa Loop examples require Bash'); + console.log('Passed: 0'); + console.log('Failed: 0'); process.exit(0); } diff --git a/tests/lib/inspection.test.js b/tests/lib/inspection.test.js index 9005dad28..80082b25b 100644 --- a/tests/lib/inspection.test.js +++ b/tests/lib/inspection.test.js @@ -172,6 +172,19 @@ async function runTests() { 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('groupFailures handles mixed outcome casing', async () => { const runs = [ makeSkillRun({ id: 'r1', outcome: 'FAILURE', failureReason: 'timeout' }), From 63daf0e89dea505172df30db80cc08ba1cb05785 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:40:59 -0400 Subject: [PATCH 63/69] fix(skill-comply): send Unicode Claude prompts through stdin --- skills/skill-comply/scripts/classifier.py | 3 +- skills/skill-comply/scripts/runner.py | 3 +- .../scripts/scenario_generator.py | 3 +- skills/skill-comply/scripts/spec_generator.py | 3 +- skills/skill-comply/tests/test_utf8.py | 37 +++++++++++++++++-- 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/skills/skill-comply/scripts/classifier.py b/skills/skill-comply/scripts/classifier.py index 281792c67..64494a961 100644 --- a/skills/skill-comply/scripts/classifier.py +++ b/skills/skill-comply/scripts/classifier.py @@ -48,7 +48,8 @@ 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, encoding="utf-8", timeout=60, diff --git a/skills/skill-comply/scripts/runner.py b/skills/skill-comply/scripts/runner.py index dd75b6a8f..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,6 +58,7 @@ def run_scenario( "--output-format", "stream-json", "--verbose", ], + input=scenario.prompt, capture_output=True, encoding="utf-8", timeout=timeout, diff --git a/skills/skill-comply/scripts/scenario_generator.py b/skills/skill-comply/scripts/scenario_generator.py index aeb262b4b..9733fe5cf 100644 --- a/skills/skill-comply/scripts/scenario_generator.py +++ b/skills/skill-comply/scripts/scenario_generator.py @@ -41,7 +41,8 @@ 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, encoding="utf-8", timeout=120, diff --git a/skills/skill-comply/scripts/spec_generator.py b/skills/skill-comply/scripts/spec_generator.py index 8d99036ef..c15382d9a 100644 --- a/skills/skill-comply/scripts/spec_generator.py +++ b/skills/skill-comply/scripts/spec_generator.py @@ -41,7 +41,8 @@ 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, encoding="utf-8", timeout=120, diff --git a/skills/skill-comply/tests/test_utf8.py b/skills/skill-comply/tests/test_utf8.py index 23142ab45..be97de9a9 100644 --- a/skills/skill-comply/tests/test_utf8.py +++ b/skills/skill-comply/tests/test_utf8.py @@ -27,7 +27,8 @@ SPEC = {"id": "unicode", "name": TEXT, "source_rule": TEXT, "version": "1", 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 TEXT in command[2] + 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 @@ -67,7 +68,7 @@ def test_generators_preserve_utf8(tmp_path: Path, monkeypatch: pytest.MonkeyPatc ]} 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 command[2] + assert PROMPT_TEXT in kwargs["input"] return execute(command, **kwargs) monkeypatch.setattr(module.subprocess, "run", checked_execute) if kind == "spec": @@ -82,7 +83,7 @@ def test_spec_retry_preserves_utf8(tmp_path: Path, monkeypatch: pytest.MonkeyPat 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(command[2]) + 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 @@ -95,7 +96,7 @@ def test_classifier_preserves_utf8(tmp_path: Path, monkeypatch: pytest.MonkeyPat 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 command[2] + 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) @@ -116,6 +117,34 @@ def test_runner_preserves_utf8_capture(tmp_path: Path, monkeypatch: pytest.Monke 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") From 071e1fea6d376f7f55e1ec2e7bb8fd831970a2ff Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:40:59 -0400 Subject: [PATCH 64/69] fix(integration): address reviewer portability and guidance --- docs/TROUBLESHOOTING.md | 2 +- docs/hook-bug-workarounds.md | 2 +- skills/continuous-learning-v2/scripts/test_parse_instinct.py | 4 ++++ tests/lib/resolve-ecc-root.test.js | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 175c8e15f..3ea2a388e 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -62,7 +62,7 @@ exit 2 **What helps:** - Reduce tool-definition pressure with `ENABLE_TOOL_SEARCH=auto:5` if your setup supports it. -- Lower the supported effort level (for example, `/effort low`) for routine work on adaptive-reasoning models; `MAX_THINKING_TOKENS` only caps fixed-budget thinking. +- Lower the supported effort level (for example, `/effort low`) for routine work on adaptive-reasoning models. `MAX_THINKING_TOKENS=0` disables thinking; nonzero values cap fixed-budget thinking only when adaptive thinking is disabled. - Route subagent work to a cheaper model such as `CLAUDE_CODE_SUBAGENT_MODEL=haiku` if your setup exposes that knob. - Disable unused MCP servers per project. - Compact manually at natural breakpoints instead of waiting for auto-compaction. diff --git a/docs/hook-bug-workarounds.md b/docs/hook-bug-workarounds.md index 7746dfa6c..cf3ce0396 100644 --- a/docs/hook-bug-workarounds.md +++ b/docs/hook-bug-workarounds.md @@ -61,7 +61,7 @@ What helps: What helps: - Reduce tool-definition pressure with `ENABLE_TOOL_SEARCH=auto:5` if your setup supports it. -- Lower the supported effort level (for example, `/effort low`) for routine work on adaptive-reasoning models; `MAX_THINKING_TOKENS` only caps fixed-budget thinking. +- Lower the supported effort level (for example, `/effort low`) for routine work on adaptive-reasoning models. `MAX_THINKING_TOKENS=0` disables thinking; nonzero values cap fixed-budget thinking only when adaptive thinking is disabled. - Route subagent work to a cheaper model such as `CLAUDE_CODE_SUBAGENT_MODEL=haiku` if your setup exposes that knob. - Disable unused MCP servers per project. - Compact manually at natural breakpoints instead of waiting for auto-compaction. diff --git a/skills/continuous-learning-v2/scripts/test_parse_instinct.py b/skills/continuous-learning-v2/scripts/test_parse_instinct.py index 76733362b..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,7 @@ 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", @@ -294,11 +295,13 @@ def test_quoted_instinct_strings_roundtrip(value: str) -> None: 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) @@ -306,6 +309,7 @@ def test_unicode_controls_are_escaped_for_yaml(value: str) -> None: 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" diff --git a/tests/lib/resolve-ecc-root.test.js b/tests/lib/resolve-ecc-root.test.js index f2b1e1482..3b7a8bad3 100644 --- a/tests/lib/resolve-ecc-root.test.js +++ b/tests/lib/resolve-ecc-root.test.js @@ -548,7 +548,7 @@ module.exports = { resolveEccRoot() { assert.strictEqual(process.env.HOME, ${JSO }); // 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, '/mnt/c/Users/test/.claude'); + assert.strictEqual(result, path.resolve('/mnt/c/Users/test/.claude')); } finally { fs.rmSync(homeDir, { recursive: true, force: true }); } From a6db53f8353b43762960d5a5bceeba343b14638d Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:41:19 -0400 Subject: [PATCH 65/69] fix(inspection): normalize diagnostic-looking Windows filenames --- scripts/lib/inspection.js | 2 +- tests/lib/inspection.test.js | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/scripts/lib/inspection.js b/scripts/lib/inspection.js index 1e24df064..931af086a 100644 --- a/scripts/lib/inspection.js +++ b/scripts/lib/inspection.js @@ -27,7 +27,7 @@ function normalizeFailureReason(reason) { // 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\\/:*?"<>|(),;]+(?: (?!failed\b|timed\b|could\b|cannot\b|unable\b|error\b)[^\s\\/:*?"<>|]+\.[^\s\\/:*?"<>|]+(?=:))?[\\/]*/gu, '') + .replace(/(?:\b[a-z]:|\\)[\\/](?:[^\\/:*?"<>|\r\n]*[^\s\\/:*?"<>|][\\/])*[^\s\\/:*?"<>|(),;]+(?: [^\s\\/:*?"<>|]+\.[^\s\\/:*?"<>|]+(?=:))?[\\/]*/gu, '') // Strip remaining file paths .replace(/(?:\b[a-z]:)?[\\/][\w./\\-]+/g, '') // Collapse whitespace diff --git a/tests/lib/inspection.test.js b/tests/lib/inspection.test.js index 80082b25b..7ded26cd1 100644 --- a/tests/lib/inspection.test.js +++ b/tests/lib/inspection.test.js @@ -185,6 +185,24 @@ async function runTests() { 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' }), From dd640d14ffccc6fbe2ed46ad63dc5f66394a6d99 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:41:45 -0400 Subject: [PATCH 66/69] ci: refresh registry digest after skill-comply repair --- manifests/context-packs/skill-triggers@1.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index 392f61980..3498bc6f6 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "76dbbdefb92aa9c06216171b2789767a0ee0c3d226a9e7eccce68b2e4f14ca58", + "registryDigest": "0e0f3b999e97c7560f5c6bc6bdf5ea9874f10fa7719f573f0c9a8d9b5c64963f", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [ From a1e84ca67a41c10d030c5469e5cda26ebc4d8f85 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:53:51 -0400 Subject: [PATCH 67/69] fix(opensource): permit only untracked reports at publish gate --- manifests/context-packs/skill-triggers@1.json | 2 +- skills/opensource-pipeline/SKILL.md | 9 +++++++-- tests/skills/opensource-forker-history.test.js | 18 ++++++++++++++++++ .../opensource-pipeline-publish-guard.test.js | 11 ++++++++++- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index cf1ef48de..34be199f4 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "4fc25981407c964d970cb203db86c094524ee0da4abd3dafe6cf13c6e386b92a", + "registryDigest": "846ac8826dd91e81cd838c2b55d7be536af427eb453efa127501f891e5778ff8", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [ diff --git a/skills/opensource-pipeline/SKILL.md b/skills/opensource-pipeline/SKILL.md index 47dd03644..3be84b1f4 100644 --- a/skills/opensource-pipeline/SKILL.md +++ b/skills/opensource-pipeline/SKILL.md @@ -229,11 +229,16 @@ Proceed with GitHub creation? (yes/no/review first) 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. +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 status --porcelain)" && test "$(git rev-list --count HEAD)" = 1 \ +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}" diff --git a/tests/skills/opensource-forker-history.test.js b/tests/skills/opensource-forker-history.test.js index 5339309d9..b8ac8a984 100644 --- a/tests/skills/opensource-forker-history.test.js +++ b/tests/skills/opensource-forker-history.test.js @@ -51,6 +51,7 @@ if (process.platform === 'win32') { 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; @@ -68,6 +69,23 @@ try { 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; diff --git a/tests/skills/opensource-pipeline-publish-guard.test.js b/tests/skills/opensource-pipeline-publish-guard.test.js index a91e46b79..7c96411d1 100644 --- a/tests/skills/opensource-pipeline-publish-guard.test.js +++ b/tests/skills/opensource-pipeline-publish-guard.test.js @@ -44,7 +44,7 @@ function git(cwd, ...args) { } // One-commit staging repo; returns the commit the sanitizer "scanned". -function withStaging(fn) { +function withStaging(fn, { committedReport = false } = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'oss-publish-')); try { const staging = path.join(root, 'staging'); @@ -56,6 +56,7 @@ function withStaging(fn) { 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'); @@ -106,6 +107,14 @@ function runTests() { }); })) 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; From 90d94d44240f8ee072a83761daab0cddc0429b01 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:46:45 -0400 Subject: [PATCH 68/69] test: fix Windows install and ck hook expectations --- tests/lib/install-targets.test.js | 6 +++--- tests/skills/ck-paths.test.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index a9ec9a95c..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', @@ -722,7 +722,7 @@ function runTests() { if (test('claude adapter falls back to home/.claude when CLAUDE_CONFIG_DIR is unset or blank', () => { const adapter = getInstallTargetAdapter('claude'); - const homeDir = '/Users/example'; + const homeDir = path.resolve('/Users/example'); assert.strictEqual( adapter.resolveRoot({ homeDir, env: {} }), diff --git a/tests/skills/ck-paths.test.js b/tests/skills/ck-paths.test.js index 9ab5d88d4..c85c5d40b 100644 --- a/tests/skills/ck-paths.test.js +++ b/tests/skills/ck-paths.test.js @@ -79,7 +79,7 @@ async function runTests() { 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 = path.join(SKILL_DIR, 'commands').replace(/[\\$`"]/g, '\\$&'); + 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 }); From 87e8d734a71f2f62bcd22ba547b10a0f7d8616ac Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:06:01 -0400 Subject: [PATCH 69/69] fix(opencode): ship audited runtime dependencies --- .opencode/package-lock.json | 10 ++++++---- .opencode/package.json | 6 ++++++ .opencode/tools/changed-files.ts | 2 +- .opencode/tools/check-coverage.ts | 2 +- .opencode/tools/dependency-analyzer.ts | 2 +- .opencode/tools/format-code.ts | 2 +- .opencode/tools/git-summary.ts | 2 +- .opencode/tools/lint-check.ts | 2 +- .opencode/tools/run-tests.ts | 2 +- .opencode/tools/security-audit.ts | 2 +- .opencode/tools/tool.ts | 10 ++++++++++ package-lock.json | 25 +++++++++++++++++-------- package.json | 15 +++++++++------ yarn.lock | 17 +++++++++-------- 14 files changed, 65 insertions(+), 34 deletions(-) create mode 100644 .opencode/tools/tool.ts 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/package-lock.json b/package-lock.json index cd840aa01..56ef88ca1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,10 @@ "license": "MIT", "dependencies": { "@iarna/toml": "2.2.5", - "@opencode-ai/plugin": "1.4.3", "ajv": "8.20.0", "js-yaml": "4.3.2", - "sql.js": "1.14.2" + "sql.js": "1.14.2", + "zod": "4.6.7" }, "bin": { "ecc": "scripts/ecc.js", @@ -25,6 +25,7 @@ }, "devDependencies": { "@eslint/js": "9.39.2", + "@opencode-ai/plugin": "1.4.3", "@types/node": "26.6.4", "c8": "11.0.0", "eslint": "10.11.0", @@ -328,6 +329,7 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.3.tgz", "integrity": "sha512-Ob/3tVSIeuMRJBr2O23RtrnC5djRe01Lglx+TwGEmjrH9yDBJ2tftegYLnNEjRoMuzITgq9LD8168p4pzv+U/A==", + "dev": true, "license": "MIT", "dependencies": { "@opencode-ai/sdk": "1.4.3", @@ -350,6 +352,7 @@ "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" @@ -714,6 +717,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1039,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", @@ -1354,6 +1358,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -2325,6 +2330,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2461,6 +2467,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -2473,6 +2480,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2671,6 +2679,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -2783,9 +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==", + "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 4231df3d2..f1c6b9075 100644 --- a/package.json +++ b/package.json @@ -543,10 +543,10 @@ }, "dependencies": { "@iarna/toml": "2.2.5", - "@opencode-ai/plugin": "1.4.3", "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": [ @@ -561,6 +561,7 @@ }, "devDependencies": { "@eslint/js": "9.39.2", + "@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/yarn.lock b/yarn.lock index a191cf3f2..a8533cbcd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -571,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 @@ -744,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 @@ -1904,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