From 52daf17cb515e45c58313c5a4cba5193c58e2ebe Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 12 Mar 2026 15:07:57 -0700 Subject: [PATCH] fix: harden orchestration status and skill docs --- .agents/skills/claude-api/SKILL.md | 4 + .agents/skills/crosspost/SKILL.md | 5 +- .agents/skills/exa-search/SKILL.md | 6 +- .agents/skills/x-api/SKILL.md | 3 + scripts/lib/orchestration-session.js | 27 ++++-- scripts/lib/tmux-worktree-orchestrator.js | 6 ++ scripts/orchestration-status.js | 31 ++++++- skills/configure-ecc/SKILL.md | 2 +- skills/crosspost/SKILL.md | 4 +- skills/exa-search/SKILL.md | 2 +- skills/fal-ai-media/SKILL.md | 2 +- skills/x-api/SKILL.md | 1 + tests/lib/orchestration-session.test.js | 29 ++++++- tests/lib/tmux-worktree-orchestrator.test.js | 11 +++ tests/scripts/orchestration-status.test.js | 89 ++++++++++++++++++++ 15 files changed, 206 insertions(+), 16 deletions(-) create mode 100644 tests/scripts/orchestration-status.test.js diff --git a/.agents/skills/claude-api/SKILL.md b/.agents/skills/claude-api/SKILL.md index b42a3e35..0c09b740 100644 --- a/.agents/skills/claude-api/SKILL.md +++ b/.agents/skills/claude-api/SKILL.md @@ -230,6 +230,8 @@ print(f"Cache creation: {message.usage.cache_creation_input_tokens}") Process large volumes asynchronously at 50% cost reduction: ```python +import time + batch = client.messages.batches.create( requests=[ { @@ -306,6 +308,8 @@ while True: ## Error Handling ```python +import time + from anthropic import APIError, RateLimitError, APIConnectionError try: diff --git a/.agents/skills/crosspost/SKILL.md b/.agents/skills/crosspost/SKILL.md index 4d235933..c20e03ec 100644 --- a/.agents/skills/crosspost/SKILL.md +++ b/.agents/skills/crosspost/SKILL.md @@ -148,6 +148,7 @@ A pattern I've been using that's made a real difference: If using a crossposting service (e.g., Postbridge, Buffer, or a custom API), the pattern looks like: ```python +import os import requests resp = requests.post( @@ -160,8 +161,10 @@ resp = requests.post( "linkedin": {"text": linkedin_version}, "threads": {"text": threads_version} } - } + }, + timeout=30 ) +resp.raise_for_status() ``` ### Manual Posting diff --git a/.agents/skills/exa-search/SKILL.md b/.agents/skills/exa-search/SKILL.md index 37ea2b1b..4e26a6b7 100644 --- a/.agents/skills/exa-search/SKILL.md +++ b/.agents/skills/exa-search/SKILL.md @@ -24,7 +24,11 @@ Exa MCP server must be configured. Add to `~/.claude.json`: ```json "exa-web-search": { "command": "npx", - "args": ["-y", "exa-mcp-server"], + "args": [ + "-y", + "exa-mcp-server", + "tools=web_search_exa,web_search_advanced_exa,get_code_context_exa,crawling_exa,company_research_exa,linkedin_search_exa,deep_researcher_start,deep_researcher_check" + ], "env": { "EXA_API_KEY": "YOUR_EXA_API_KEY_HERE" } } ``` diff --git a/.agents/skills/x-api/SKILL.md b/.agents/skills/x-api/SKILL.md index d0e3ad65..a88a2e8f 100644 --- a/.agents/skills/x-api/SKILL.md +++ b/.agents/skills/x-api/SKILL.md @@ -92,6 +92,7 @@ def post_thread(oauth, tweets: list[str]) -> list[str]: if reply_to: payload["reply"] = {"in_reply_to_tweet_id": reply_to} resp = oauth.post("https://api.x.com/2/tweets", json=payload) + resp.raise_for_status() tweet_id = resp.json()["data"]["id"] ids.append(tweet_id) reply_to = tweet_id @@ -167,6 +168,8 @@ resp = oauth.post( Always check `x-rate-limit-remaining` and `x-rate-limit-reset` headers. ```python +import time + remaining = int(resp.headers.get("x-rate-limit-remaining", 0)) if remaining < 5: reset = int(resp.headers.get("x-rate-limit-reset", 0)) diff --git a/scripts/lib/orchestration-session.js b/scripts/lib/orchestration-session.js index f544714d..1eff6340 100644 --- a/scripts/lib/orchestration-session.js +++ b/scripts/lib/orchestration-session.js @@ -17,6 +17,16 @@ function stripCodeTicks(value) { return trimmed; } +function normalizeSessionName(value, fallback = 'session') { + const normalized = String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return normalized || fallback; +} + function parseSection(content, heading) { if (typeof content !== 'string' || content.length === 0) { return ''; @@ -246,22 +256,29 @@ function resolveSnapshotTarget(targetPath, cwd = process.cwd()) { if (fs.existsSync(absoluteTarget) && fs.statSync(absoluteTarget).isFile()) { const config = JSON.parse(fs.readFileSync(absoluteTarget, 'utf8')); const repoRoot = path.resolve(config.repoRoot || cwd); + const sessionName = normalizeSessionName( + config.sessionName || path.basename(repoRoot), + 'session' + ); const coordinationRoot = path.resolve( config.coordinationRoot || path.join(repoRoot, '.orchestration') ); return { - sessionName: config.sessionName, - coordinationDir: path.join(coordinationRoot, config.sessionName), + sessionName, + coordinationDir: path.join(coordinationRoot, sessionName), repoRoot, targetType: 'plan' }; } + const repoRoot = path.resolve(cwd); + const sessionName = normalizeSessionName(targetPath, path.basename(repoRoot)); + return { - sessionName: targetPath, - coordinationDir: path.join(cwd, '.claude', 'orchestration', targetPath), - repoRoot: cwd, + sessionName, + coordinationDir: path.join(repoRoot, '.orchestration', sessionName), + repoRoot, targetType: 'session' }; } diff --git a/scripts/lib/tmux-worktree-orchestrator.js b/scripts/lib/tmux-worktree-orchestrator.js index 4cf8bb4d..902ddbe1 100644 --- a/scripts/lib/tmux-worktree-orchestrator.js +++ b/scripts/lib/tmux-worktree-orchestrator.js @@ -56,6 +56,12 @@ function normalizeSeedPaths(seedPaths, repoRoot) { } const normalizedPath = relativePath.split(path.sep).join('/'); + if (!normalizedPath || normalizedPath === '.') { + throw new Error('seedPaths entries must not target the repo root'); + } + if (normalizedPath === '.git' || normalizedPath.startsWith('.git/')) { + throw new Error(`seedPaths entries must not target git metadata: ${entry}`); + } if (seen.has(normalizedPath)) { continue; } diff --git a/scripts/orchestration-status.js b/scripts/orchestration-status.js index 309a9f3d..5ac7bca3 100644 --- a/scripts/orchestration-status.js +++ b/scripts/orchestration-status.js @@ -20,9 +20,32 @@ function usage() { function parseArgs(argv) { const args = argv.slice(2); - const target = args.find(arg => !arg.startsWith('--')); - const writeIndex = args.indexOf('--write'); - const writePath = writeIndex >= 0 ? args[writeIndex + 1] : null; + let target = null; + let writePath = null; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === '--write') { + const candidate = args[index + 1]; + if (!candidate || candidate.startsWith('--')) { + throw new Error('--write requires an output path'); + } + writePath = candidate; + index += 1; + continue; + } + + if (arg.startsWith('--')) { + throw new Error(`Unknown flag: ${arg}`); + } + + if (target) { + throw new Error('Expected a single session name or plan path'); + } + + target = arg; + } return { target, writePath }; } @@ -56,4 +79,4 @@ if (require.main === module) { } } -module.exports = { main }; +module.exports = { main, parseArgs }; diff --git a/skills/configure-ecc/SKILL.md b/skills/configure-ecc/SKILL.md index a762868b..110d82e9 100644 --- a/skills/configure-ecc/SKILL.md +++ b/skills/configure-ecc/SKILL.md @@ -82,7 +82,7 @@ If the user chooses niche or core + niche, continue to category selection below ### 2b: Choose Skill Categories -There are 35 skills organized into 7 categories. Use `AskUserQuestion` with `multiSelect: true`: +There are 41 skills organized into 8 categories. Use `AskUserQuestion` with `multiSelect: true`: ``` Question: "Which skill categories do you want to install?" diff --git a/skills/crosspost/SKILL.md b/skills/crosspost/SKILL.md index 81e2a29d..937b0f0e 100644 --- a/skills/crosspost/SKILL.md +++ b/skills/crosspost/SKILL.md @@ -161,8 +161,10 @@ resp = requests.post( "linkedin": {"text": linkedin_version}, "threads": {"text": threads_version} } - } + }, + timeout=30 ) +resp.raise_for_status() ``` ### Manual Posting diff --git a/skills/exa-search/SKILL.md b/skills/exa-search/SKILL.md index f3cffb8d..8a8059b8 100644 --- a/skills/exa-search/SKILL.md +++ b/skills/exa-search/SKILL.md @@ -27,7 +27,7 @@ Exa MCP server must be configured. Add to `~/.claude.json`: "args": [ "-y", "exa-mcp-server", - "tools=web_search_exa,get_code_context_exa,crawling_exa,company_research_exa,linkedin_search_exa,deep_researcher_start,deep_researcher_check" + "tools=web_search_exa,web_search_advanced_exa,get_code_context_exa,crawling_exa,company_research_exa,linkedin_search_exa,deep_researcher_start,deep_researcher_check" ], "env": { "EXA_API_KEY": "YOUR_EXA_API_KEY_HERE" } } diff --git a/skills/fal-ai-media/SKILL.md b/skills/fal-ai-media/SKILL.md index 254be99e..ffe701d1 100644 --- a/skills/fal-ai-media/SKILL.md +++ b/skills/fal-ai-media/SKILL.md @@ -253,7 +253,7 @@ estimate_cost( estimate_type: "unit_price", endpoints: { "fal-ai/nano-banana-pro": { - "num_images": 1 + "unit_quantity": 1 } } ) diff --git a/skills/x-api/SKILL.md b/skills/x-api/SKILL.md index 23346c49..560a1e07 100644 --- a/skills/x-api/SKILL.md +++ b/skills/x-api/SKILL.md @@ -92,6 +92,7 @@ def post_thread(oauth, tweets: list[str]) -> list[str]: if reply_to: payload["reply"] = {"in_reply_to_tweet_id": reply_to} resp = oauth.post("https://api.x.com/2/tweets", json=payload) + resp.raise_for_status() tweet_id = resp.json()["data"]["id"] ids.append(tweet_id) reply_to = tweet_id diff --git a/tests/lib/orchestration-session.test.js b/tests/lib/orchestration-session.test.js index 21e7bfae..4bd4b340 100644 --- a/tests/lib/orchestration-session.test.js +++ b/tests/lib/orchestration-session.test.js @@ -204,7 +204,34 @@ test('resolveSnapshotTarget handles plan files and direct session names', () => const fromSession = resolveSnapshotTarget('workflow-visual-proof', repoRoot); assert.strictEqual(fromSession.targetType, 'session'); - assert.ok(fromSession.coordinationDir.endsWith(path.join('.claude', 'orchestration', 'workflow-visual-proof'))); + assert.ok(fromSession.coordinationDir.endsWith(path.join('.orchestration', 'workflow-visual-proof'))); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('resolveSnapshotTarget normalizes plan session names and defaults to the repo name', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orch-target-')); + const repoRoot = path.join(tempRoot, 'My Repo'); + fs.mkdirSync(repoRoot, { recursive: true }); + + const namedPlanPath = path.join(repoRoot, 'named-plan.json'); + const defaultPlanPath = path.join(repoRoot, 'default-plan.json'); + + fs.writeFileSync(namedPlanPath, JSON.stringify({ + sessionName: 'Workflow Visual Proof', + repoRoot + })); + fs.writeFileSync(defaultPlanPath, JSON.stringify({ repoRoot })); + + try { + const namedPlan = resolveSnapshotTarget(namedPlanPath, repoRoot); + assert.strictEqual(namedPlan.sessionName, 'workflow-visual-proof'); + assert.ok(namedPlan.coordinationDir.endsWith(path.join('.orchestration', 'workflow-visual-proof'))); + + const defaultPlan = resolveSnapshotTarget(defaultPlanPath, repoRoot); + assert.strictEqual(defaultPlan.sessionName, 'my-repo'); + assert.ok(defaultPlan.coordinationDir.endsWith(path.join('.orchestration', 'my-repo'))); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/tests/lib/tmux-worktree-orchestrator.test.js b/tests/lib/tmux-worktree-orchestrator.test.js index fcbe1b71..c6a657eb 100644 --- a/tests/lib/tmux-worktree-orchestrator.test.js +++ b/tests/lib/tmux-worktree-orchestrator.test.js @@ -144,6 +144,17 @@ test('normalizeSeedPaths rejects paths outside the repo root', () => { ); }); +test('normalizeSeedPaths rejects repo root and git metadata paths', () => { + assert.throws( + () => normalizeSeedPaths(['.'], '/tmp/ecc'), + /must not target the repo root/ + ); + assert.throws( + () => normalizeSeedPaths(['.git/config'], '/tmp/ecc'), + /must not target git metadata/ + ); +}); + test('materializePlan keeps worker instructions inside the worktree boundary', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-orchestrator-test-')); diff --git a/tests/scripts/orchestration-status.test.js b/tests/scripts/orchestration-status.test.js new file mode 100644 index 00000000..4d33ce25 --- /dev/null +++ b/tests/scripts/orchestration-status.test.js @@ -0,0 +1,89 @@ +'use strict'; + +const assert = require('assert'); + +const { parseArgs } = require('../../scripts/orchestration-status'); + +console.log('=== Testing orchestration-status.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (error) { + console.log(` ✗ ${desc}: ${error.message}`); + failed++; + } +} + +test('parseArgs reads a target with an optional write path', () => { + assert.deepStrictEqual( + parseArgs([ + 'node', + 'scripts/orchestration-status.js', + 'workflow-visual-proof', + '--write', + '/tmp/snapshot.json' + ]), + { + target: 'workflow-visual-proof', + writePath: '/tmp/snapshot.json' + } + ); +}); + +test('parseArgs does not treat the write path as the target', () => { + assert.deepStrictEqual( + parseArgs([ + 'node', + 'scripts/orchestration-status.js', + '--write', + '/tmp/snapshot.json', + 'workflow-visual-proof' + ]), + { + target: 'workflow-visual-proof', + writePath: '/tmp/snapshot.json' + } + ); +}); + +test('parseArgs rejects missing write values and unknown flags', () => { + assert.throws( + () => parseArgs([ + 'node', + 'scripts/orchestration-status.js', + 'workflow-visual-proof', + '--write' + ]), + /--write requires an output path/ + ); + assert.throws( + () => parseArgs([ + 'node', + 'scripts/orchestration-status.js', + 'workflow-visual-proof', + '--unknown' + ]), + /Unknown flag/ + ); +}); + +test('parseArgs rejects multiple positional targets', () => { + assert.throws( + () => parseArgs([ + 'node', + 'scripts/orchestration-status.js', + 'first', + 'second' + ]), + /Expected a single session name or plan path/ + ); +}); + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); +if (failed > 0) process.exit(1);