Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions .opencode/plugins/superpowers.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));

// Simple frontmatter extraction (avoid dependency on skills-core for bootstrap)
const extractAndStripFrontmatter = (content) => {
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { frontmatter: {}, content };

const frontmatterStr = match[1];
Expand All @@ -33,6 +33,13 @@ const extractAndStripFrontmatter = (content) => {
return { frontmatter, content: body };
};

// Validate that a resolved path doesn't contain prompt-injection vectors
const sanitizePath = (p) => {
if (!p || typeof p !== 'string') return null;
if (/[`${}<>\n\r]/.test(p)) return null;
return p;
};

// Normalize a path: trim whitespace, expand ~, resolve to absolute
const normalizePath = (p, homeDir) => {
if (!p || typeof p !== 'string') return null;
Expand All @@ -49,7 +56,7 @@ const normalizePath = (p, homeDir) => {
export const SuperpowersPlugin = async ({ client, directory }) => {
const homeDir = os.homedir();
const superpowersSkillsDir = path.resolve(__dirname, '../../skills');
const envConfigDir = normalizePath(process.env.OPENCODE_CONFIG_DIR, homeDir);
const envConfigDir = sanitizePath(normalizePath(process.env.OPENCODE_CONFIG_DIR, homeDir));
const configDir = envConfigDir || path.join(homeDir, '.config/opencode');

// Helper to generate bootstrap content
Expand All @@ -58,7 +65,12 @@ export const SuperpowersPlugin = async ({ client, directory }) => {
const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md');
if (!fs.existsSync(skillPath)) return null;

const fullContent = fs.readFileSync(skillPath, 'utf8');
let fullContent;
try {
fullContent = fs.readFileSync(skillPath, 'utf8');
} catch {
return null;
}
const { content } = extractAndStripFrontmatter(fullContent);

const toolMapping = `**Tool Mapping for OpenCode:**
Expand Down
113 changes: 113 additions & 0 deletions bin/kp-post-office
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
set -e

# kp-post-office
# A POSIX Maildir-compliant dispatcher for kinder·powers.
# Usage:
# kp-post-office init <role>
# kp-post-office drop <role> "Task Description"
# kp-post-office watch <role> <target_repo>

COMMAND=$1
ROLE=$2
MAIL_ROOT="${MAILDIR:-$HOME/.beads/mail}"
ROLE_DIR="$MAIL_ROOT/$ROLE"

show_help() {
echo "kp-post-office: POSIX Maildir agent dispatcher"
echo "Usage:"
echo " kp-post-office init <role>"
echo " kp-post-office drop <role> \"Task context or instructions\""
echo " kp-post-office watch <role> <target_repo>"
exit 1
}

if [ -z "$COMMAND" ] || [ -z "$ROLE" ]; then
show_help
fi

case "$COMMAND" in
init)
mkdir -p "$ROLE_DIR"/{new,cur,tmp,done}
chmod 1777 "$MAIL_ROOT" 2>/dev/null || true # Best effort sticky bit
echo "✓ Maildir initialized at $ROLE_DIR"
;;

drop)
PAYLOAD=$3
if [ -z "$PAYLOAD" ]; then
echo "Error: Must provide a task payload to drop."
exit 1
fi
mkdir -p "$ROLE_DIR"/{new,cur,tmp,done}

# Maildir delivery protocol: write to tmp, then link/rename to new
MSG_ID="$(date +%s).$$"
TMP_FILE="$ROLE_DIR/tmp/$MSG_ID"
NEW_FILE="$ROLE_DIR/new/$MSG_ID"

echo "$PAYLOAD" > "$TMP_FILE"
mv "$TMP_FILE" "$NEW_FILE"
echo "✓ Mail dropped for $ROLE: $MSG_ID"
;;

watch)
REPO=$3
if [ -z "$REPO" ] || [ ! -d "$REPO" ]; then
echo "Error: Must provide a valid <target_repo> for the worker to execute in."
exit 1
fi

mkdir -p "$ROLE_DIR"/{new,cur,tmp,done}
echo "ℹ Watching $ROLE_DIR/new for role: $ROLE..."
echo "ℹ Workers will be forked into: $REPO"

while true; do
# Use find to cleanly iterate over files
for msg in "$ROLE_DIR"/new/*; do
if [ -f "$msg" ]; then
base=$(basename "$msg")
CUR_FILE="$ROLE_DIR/cur/$base"

# Atomic Claim: mv from new to cur
if mv "$msg" "$CUR_FILE" 2>/dev/null; then
echo "✓ Claimed mail $base. Forking Claude worker..."

# FORK WORKER (runs in subshell)
(
# 1. Switch to working repo
cd "$REPO" || exit 1

# 2. Read mail
TASK_CONTENT=$(cat "$CUR_FILE")

echo "[Worker $base] Starting in $REPO..."

# 3. Execute Claude (The Agent)
# Using the standard claude CLI, injecting the task
# We pipe yes to avoid it blocking forever if it expects stdin,
# or just run it as a command.
if command -v claude &> /dev/null; then
claude -p "You are the '$ROLE' agent. Execute the following task. When finished, summarize your work. Task: $TASK_CONTENT"
else
echo "[Worker $base] 'claude' command not found. Mocking execution..."
sleep 2
fi

# 4. Mark Done
mv "$CUR_FILE" "$ROLE_DIR/done/$base"
echo "[Worker $base] Finished and archived."
) &

# We don't wait for the background worker; we go back to polling mail.
fi
fi
done
sleep 2
done
;;

*)
show_help
;;
esac
20 changes: 19 additions & 1 deletion hooks/agent-outcome-logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@
from datetime import datetime, timezone
from pathlib import Path

MAX_FILE_BYTES = 10 * 1024 * 1024 # 10 MB


def _rotate_if_needed(out_file: Path) -> None:
"""Rotate log file if it exceeds MAX_FILE_BYTES. Never raises."""
try:
if out_file.exists() and out_file.stat().st_size > MAX_FILE_BYTES:
rotated = out_file.with_suffix(".jsonl.1")
out_file.replace(rotated)
except Exception as exc:
print(f"agent-outcome-logger: rotation failed: {exc}", file=sys.stderr)


def main():
try:
Expand Down Expand Up @@ -59,9 +71,15 @@ def main():
out_dir.mkdir(parents=True, exist_ok=True)
out_file = out_dir / "agent_outcomes.jsonl"

_rotate_if_needed(out_file)

with open(out_file, "a") as f:
f.write(json.dumps(record, separators=(",", ":")) + "\n")


if __name__ == "__main__":
main()
try:
main()
except Exception as exc:
print(f"agent-outcome-logger: unexpected error: {exc}", file=sys.stderr)
sys.exit(0)
81 changes: 81 additions & 0 deletions lib/comms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import httpx
import subprocess
import shutil
import logging
import time

logger = logging.getLogger(__name__)

def vm_generate(prompt: str, port: int = 8765, model: str = "default", max_tokens: int = 4096, timeout: float = 30.0, json_schema: dict | None = None) -> str | None:
"""Talk to any OpenAI-compatible local server. Returns str or None."""
body = {"model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens}
if json_schema:
body["response_format"] = {"type": "json_schema", "json_schema": json_schema}
last_exc: Exception | None = None
for attempt in range(2):
try:
r = httpx.post(f"http://127.0.0.1:{port}/v1/chat/completions", json=body, timeout=timeout)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
except (httpx.ConnectError, httpx.TimeoutException) as e:
last_exc = e
logger.debug(f"vm_generate [{type(e).__name__}] port {port}: {e}")
if attempt < 1:
time.sleep(1)
continue
except Exception as e:
logger.debug(f"vm_generate [{type(e).__name__}] port {port}: {e}")
return None
logger.debug(f"vm_generate [{type(last_exc).__name__}] port {port}: retries exhausted")
return None

def gemini_generate(prompt: str, max_tokens: int = 4096) -> str | None:
"""Gemini CLI (free OAuth). Returns str or None."""
if not shutil.which("gemini"):
return None
try:
r = subprocess.run(["gemini", "generate", "--max-tokens", str(max_tokens), prompt],
capture_output=True, text=True, timeout=60)
return r.stdout.strip() if r.returncode == 0 else None
except Exception as e:
logger.debug(f"gemini_generate failed: {e}")
return None

def generate(prompt: str, max_tokens: int = 4096, json_schema: dict | None = None) -> str:
"""Cheapest-first: gemini CLI ($0) → GPU ($0) → cmax ($$$)."""
# 1. Gemini CLI (free) - CLI doesn't support json_schema
if not json_schema:
result = gemini_generate(prompt, max_tokens)
if result:
return result

# 2. Local GPU
result = vm_generate(prompt, port=8765, max_tokens=max_tokens, json_schema=json_schema)
if result:
return result

# 3. cmax (costs money)
result = vm_generate(prompt, port=8889, model="claude-sonnet-4-6-20250514", max_tokens=max_tokens, json_schema=json_schema)
if result:
return result

raise RuntimeError("All inference backends failed")

def health(port: int = 8765) -> bool:
"""Check if a local VM is alive."""
try:
r = httpx.get(f"http://127.0.0.1:{port}/v1/models", timeout=5)
return r.status_code == 200
except Exception:
return False

def discover() -> dict[str, int]:
"""Find all live local inference servers.

Security note: probes localhost ports without authentication.
Intended for local dev environments only — do not expose the
returned port map to untrusted callers or use in production
without adding auth checks.
"""
ports = {"gpu": 8765, "cmax": 8889, "ollama": 11434, "lmstudio": 1234, "vllm": 8000}
return {name: port for name, port in ports.items() if health(port)}
28 changes: 27 additions & 1 deletion lib/skills-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,25 @@ function extractFrontmatter(filePath) {

return { name, description };
} catch (error) {
console.error(`[skills-core] Failed to extract frontmatter from ${filePath}:`, error.message);
return { name: '', description: '' };
}
}

/**
* Validate a skill name to prevent path traversal.
* Rejects names containing path separators, '..' sequences, or invalid characters.
*
* @param {string} name - Skill name (after stripping any 'superpowers:' prefix)
* @returns {string | null} - Validated name, or null if invalid
*/
function validateSkillName(name) {
if (!name || typeof name !== 'string') return null;
if (name.includes('/') || name.includes('\\') || name.includes('..')) return null;
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) return null;
return name;
}

/**
* Find all SKILL.md files in a directory recursively.
*
Expand All @@ -70,6 +85,11 @@ function findSkillsInDir(dir, sourceType, maxDepth = 3) {
const entries = fs.readdirSync(currentDir, { withFileTypes: true });

for (const entry of entries) {
// Defense-in-depth: entry.name comes from readdirSync (OS-provided,
// already resolved), but validate anyway to guard against future
// refactors that might pass untrusted names through this path.
if (!validateSkillName(entry.name)) continue;

const fullPath = path.join(currentDir, entry.name);

if (entry.isDirectory()) {
Expand Down Expand Up @@ -110,6 +130,11 @@ function resolveSkillPath(skillName, superpowersDir, personalDir) {
const forceSuperpowers = skillName.startsWith('superpowers:');
const actualSkillName = forceSuperpowers ? skillName.replace(/^superpowers:/, '') : skillName;

// Validate skill name to prevent path traversal
if (!validateSkillName(actualSkillName)) {
return null;
}

// Try personal skills first (unless explicitly superpowers:)
if (!forceSuperpowers && personalDir) {
const personalPath = path.join(personalDir, actualSkillName);
Expand Down Expand Up @@ -204,5 +229,6 @@ export {
findSkillsInDir,
resolveSkillPath,
checkForUpdates,
stripFrontmatter
stripFrontmatter,
validateSkillName
};
Loading
Loading