diff --git a/plugins/pi-automaton-context/index.ts b/plugins/pi-automaton-context/index.ts new file mode 100644 index 0000000..c2a6738 --- /dev/null +++ b/plugins/pi-automaton-context/index.ts @@ -0,0 +1,141 @@ +import type { ExtensionAPI, ExtensionContext, BeforeAgentStartEventResult } from "@earendil-works/pi-coding-agent"; +import { existsSync, readFileSync, readdirSync, statSync } from "fs"; +import { basename, join } from "path"; +import { homedir } from "os"; + +const AUTOMATON_HOME = join(homedir(), ".automaton"); +const STALE_MINUTES = 30; + +interface TaskInfo { + name: string; + phase: string; + mtime: Date; +} + +function getTasks(autoDir: string): TaskInfo[] { + const tasksDir = join(autoDir, ".automaton", "tasks"); + if (!existsSync(tasksDir)) return []; + try { + const entries = readdirSync(tasksDir); + const tasks: TaskInfo[] = []; + for (const entry of entries) { + const stateFile = join(tasksDir, entry, ".state"); + if (existsSync(stateFile)) { + try { + const phase = readFileSync(stateFile, "utf-8").trim(); + if (!phase) continue; + const stat = statSync(stateFile); + tasks.push({ name: entry, phase, mtime: stat.mtime }); + } catch { + // skip unreadable + } + } + } + tasks.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + return tasks; + } catch { + return []; + } +} + +function getLoopInfo(autoDir: string): string[] { + const loopsDir = join(autoDir, ".automaton", "loops"); + if (!existsSync(loopsDir)) return []; + try { + const entries = readdirSync(loopsDir); + const lines: string[] = []; + for (const entry of entries) { + const stateFile = join(loopsDir, entry, ".state.loop"); + if (existsSync(stateFile)) { + try { + const content = readFileSync(stateFile, "utf-8").trim(); + const state = JSON.parse(content); + const taskRef = state.current_task ? `, active task: ${state.current_task}` : ""; + lines.push(`Loop "${entry}": ${state.status || "unknown"}${taskRef}`); + } catch { + // skip unparseable + } + } + } + return lines; + } catch { + return []; + } +} + +export default function (pi: ExtensionAPI) { + pi.on("before_agent_start", async (_event, ctx): Promise => { + const cwd = ctx.cwd; + const inFramework = cwd === AUTOMATON_HOME || cwd.startsWith(AUTOMATON_HOME + "/"); + + let baseDir: string | null = null; + let scopeLabel: string; + + if (inFramework) { + baseDir = AUTOMATON_HOME; + scopeLabel = "framework (~/.automaton/)"; + } else { + const projectAuto = join(cwd, ".automaton"); + if (existsSync(projectAuto)) { + baseDir = cwd; + const projectName = basename(cwd) || "project"; + scopeLabel = `project (${projectName}/.automaton/)`; + } else { + return; // not in automaton context + } + } + + const tasks = getTasks(baseDir); + const editTasks = tasks.filter((t) => t.phase === "implement" || t.phase === "doc_review"); + const currentTask = editTasks.length > 0 ? editTasks[0] : tasks.length > 0 ? tasks[0] : null; + + const lines: string[] = []; + lines.push(`Automaton scope: ${scopeLabel}`); + lines.push(""); + + lines.push("How Automaton works:"); + lines.push("- Automaton enforces a task phase state machine. File edits are ONLY allowed when a task is in 'implement' or 'doc_review' phase."); + lines.push("- When the user asks you to build, fix, or change something, you MUST first create a task (use automaton_create_task) and transition it to 'implement' before you can edit any files."); + lines.push("- Without a task in 'implement', the automaton-guard-pi extension will BLOCK all file edits."); + lines.push(`- Available tools: automaton_create_task (create+transition), automaton_transition (change phase), automaton_status (check state).`); + lines.push(""); + + if (currentTask) { + lines.push(`State: Active task "${currentTask.name}" is in "${currentTask.phase}" phase.`); + + if (currentTask.phase === "implement" || currentTask.phase === "doc_review") { + lines.push(`You CAN edit files under this task.`); + const ageMinutes = Math.round((Date.now() - currentTask.mtime.getTime()) / 60000); + if (ageMinutes > STALE_MINUTES) { + lines.push( + `Note: task has been in ${currentTask.phase} for ${ageMinutes} min and is considered stale. ` + + `Run automaton_transition (or touch via status.py) if still active.`, + ); + } + } else { + lines.push(`File edits are BLOCKED. Call automaton_transition to move it to 'implement' before editing.`); + } + } else { + lines.push(`State: No active tasks. When the user makes a work request, call automaton_create_task first.`); + } + + const byPhase = new Map(); + for (const t of tasks) { + byPhase.set(t.phase, (byPhase.get(t.phase) || 0) + 1); + } + const summary = Array.from(byPhase.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([p, c]) => `${p} (${c})`) + .join(", "); + lines.push(`All tasks: ${summary || "none"}`); + + const loopInfo = getLoopInfo(baseDir); + for (const l of loopInfo) { + lines.push(l); + } + + return { + systemPrompt: `${_event.systemPrompt}\n\n## Automaton Context\n\n${lines.join("\n")}`, + }; + }); +} diff --git a/plugins/pi-automaton-context/package.json b/plugins/pi-automaton-context/package.json new file mode 100644 index 0000000..2424885 --- /dev/null +++ b/plugins/pi-automaton-context/package.json @@ -0,0 +1,10 @@ +{ + "name": "pi-automaton-context", + "version": "1.0.0", + "description": "Auto-injects Automaton framework context into Pi Dev system prompt — no more manual copy-paste of pi-automaton.sh output", + "main": "index.ts", + "type": "module", + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*" + } +} diff --git a/plugins/pi-automaton-tools/index.ts b/plugins/pi-automaton-tools/index.ts new file mode 100644 index 0000000..e294bc9 --- /dev/null +++ b/plugins/pi-automaton-tools/index.ts @@ -0,0 +1,171 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { execSync } from "child_process"; +import { existsSync, readFileSync, readdirSync } from "fs"; +import { join } from "path"; +import { homedir } from "os"; + +const STATUS_SCRIPT = join(homedir(), ".automaton", "scripts", "status.py"); + +function resolveProjectDir(cwd: string): string | null { + const inFramework = cwd === join(homedir(), ".automaton") || cwd.startsWith(join(homedir(), ".automaton") + "/"); + if (inFramework) return homedir() + "/.automaton"; + if (existsSync(join(cwd, ".automaton"))) return cwd; + return null; +} + +function getTasks(projectDir: string) { + const tasksDir = join(projectDir, ".automaton", "tasks"); + if (!existsSync(tasksDir)) return []; + const tasks: { name: string; phase: string }[] = []; + for (const entry of readdirSync(tasksDir)) { + const stateFile = join(tasksDir, entry, ".state"); + if (existsSync(stateFile)) { + try { + const phase = readFileSync(stateFile, "utf-8").trim(); + if (phase) tasks.push({ name: entry, phase }); + } catch {} + } + } + tasks.sort((a, b) => a.name.localeCompare(b.name)); + return tasks; +} + +export default function (pi: ExtensionAPI) { + pi.registerTool({ + name: "automaton_create_task", + label: "Create Automaton Task", + description: + "Create a new automaton task and transition it to the specified phase. " + + "Use this when the user asks you to do work that requires file edits — you need a task in 'implement' or 'doc_review' phase before you can edit files.", + promptSnippet: "Create automaton tasks for work management", + promptGuidelines: [ + "When the user asks you to build, implement, fix, or change something, first call automaton_create_task to create a task and transition it to 'implement' phase", + "Only after the task is in 'implement' can you edit files — the guard will block edits otherwise", + "Use a descriptive task name based on what the user wants (e.g., 'add-login-page', 'fix-api-timeout')", + "Default phase is 'implement' — omit phase for most cases", + ], + parameters: Type.Object({ + name: Type.String({ minLength: 1, description: "Task name (kebab-case, e.g. 'add-login-page')" }), + phase: Type.Optional(Type.String({ default: "implement", description: "Phase to transition to after creation" })), + }), + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + const { name, phase = "implement" } = params; + const projectDir = resolveProjectDir(process.cwd()); + if (!projectDir) { + return { + content: [{ type: "text", text: "Not in an automaton-managed project. No .automaton/ directory found." }], + details: {}, + }; + } + + try { + const createOut = execSync( + `python3 ${STATUS_SCRIPT} --create-task ${JSON.stringify(name)} --project ${JSON.stringify(projectDir)}`, + { encoding: "utf-8", timeout: 10000 }, + ); + const transitionOut = execSync( + `python3 ${STATUS_SCRIPT} --transition ${phase} --task ${JSON.stringify(name)} --project ${JSON.stringify(projectDir)}`, + { encoding: "utf-8", timeout: 10000 }, + ); + + return { + content: [{ type: "text", text: `Created task "${name}" and transitioned to "${phase}".` }], + details: { task: name, phase }, + }; + } catch (e: any) { + return { + content: [{ + type: "text", + text: `Failed to create task: ${e.stderr || e.message || String(e)}`, + }], + details: { error: e.stderr || e.message, task: name }, + }; + } + }, + }); + + pi.registerTool({ + name: "automaton_transition", + label: "Transition Automaton Task", + description: + "Transition an existing automaton task to a new phase. " + + "Valid phases: research, decomposition, design, implement, test_design, testing, doc_review, complete. " + + "Use this to move a task forward (e.g. from research to implement).", + promptSnippet: "Transition automaton tasks between phases", + parameters: Type.Object({ + task: Type.String({ minLength: 1, description: "Task name" }), + phase: Type.String({ minLength: 1, description: "Target phase" }), + }), + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + const { task, phase } = params; + const projectDir = resolveProjectDir(process.cwd()); + if (!projectDir) { + return { + content: [{ type: "text", text: "Not in an automaton-managed project." }], + details: {}, + }; + } + + try { + execSync( + `python3 ${STATUS_SCRIPT} --transition ${phase} --task ${JSON.stringify(task)} --project ${JSON.stringify(projectDir)}`, + { encoding: "utf-8", timeout: 10000 }, + ); + return { + content: [{ type: "text", text: `Task "${task}" transitioned to "${phase}".` }], + details: { task, phase }, + }; + } catch (e: any) { + return { + content: [{ + type: "text", + text: `Failed to transition task: ${e.stderr || e.message || String(e)}`, + }], + details: { error: e.stderr || e.message, task, phase }, + }; + } + }, + }); + + pi.registerTool({ + name: "automaton_status", + label: "Automaton Status", + description: + "Show current automaton project status — all tasks, their phases, and any running loops. " + + "Call this to check what state things are in before deciding what to do.", + promptSnippet: "Check automaton project status", + parameters: Type.Object({}), + async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { + const projectDir = resolveProjectDir(process.cwd()); + if (!projectDir) { + return { + content: [{ type: "text", text: "Not in an automaton-managed project." }], + details: {}, + }; + } + + const tasks = getTasks(projectDir); + const editTasks = tasks.filter((t) => t.phase === "implement" || t.phase === "doc_review"); + const currentTask = editTasks.length > 0 ? editTasks[0] : tasks.length > 0 ? tasks[0] : null; + + const lines: string[] = []; + lines.push(`Project: ${projectDir.split("/").pop()}`); + if (currentTask) { + lines.push(`Current: "${currentTask.name}" (${currentTask.phase})`); + } else { + lines.push("No tasks. Create one with automaton_create_task."); + } + lines.push(""); + for (const t of tasks) { + const marker = currentTask && t.name === currentTask.name ? ">" : " "; + lines.push(`${marker} ${t.name.padEnd(35)} ${t.phase}`); + } + + return { + content: [{ type: "text", text: lines.join("\n") }], + details: { tasks: tasks.length, current: currentTask?.name || null }, + }; + }, + }); +} diff --git a/plugins/pi-automaton-tools/package.json b/plugins/pi-automaton-tools/package.json new file mode 100644 index 0000000..1864846 --- /dev/null +++ b/plugins/pi-automaton-tools/package.json @@ -0,0 +1,10 @@ +{ + "name": "pi-automaton-tools", + "version": "1.0.0", + "description": "Registers automaton_create_task and automaton_transition tools so the agent can manage task lifecycle automatically", + "main": "index.ts", + "type": "module", + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*" + } +} diff --git a/plugins/pi-read-tweet/index.ts b/plugins/pi-read-tweet/index.ts new file mode 100644 index 0000000..f7d8c75 --- /dev/null +++ b/plugins/pi-read-tweet/index.ts @@ -0,0 +1,91 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; + +const BIRD_BIN = "/opt/homebrew/bin/bird"; + +const TweetParams = Type.Object({ + url: Type.String({ minLength: 1, description: "Tweet URL or numeric ID" }), + mode: Type.Optional( + Type.Union([ + Type.Literal("read"), + Type.Literal("thread"), + Type.Literal("replies"), + ]), + ), +}); + +export default function (pi: ExtensionAPI) { + pi.registerTool({ + name: "read_tweet", + label: "Read Tweet", + description: + "Read an X/Twitter tweet or thread using the local bird CLI. " + + "Returns tweet author, text, media, metrics, and timestamps. " + + "Use this instead of webfetch for x.com/twitter.com URLs.", + promptSnippet: "Read X/Twitter tweets with bird CLI", + promptGuidelines: [ + "Use read_tweet when the user shares an x.com or twitter.com URL and asks what it says", + "Use mode='thread' for full conversation threads", + "Use mode='replies' to fetch replies to a tweet", + "If fetch fails with auth errors, ask the user to sign in to x.com in their browser and retry", + ], + parameters: TweetParams, + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + const { url, mode = "read" } = params; + + try { + const { execSync } = await import("child_process"); + const cmd = `${BIRD_BIN} ${mode} ${JSON.stringify(url)} --json`; + const stdout = execSync(cmd, { encoding: "utf-8", timeout: 15000 }); + const parsed = JSON.parse(stdout); + + const author = parsed.author?.name || parsed.author?.screen_name || "unknown"; + const text = parsed.text || parsed.content || ""; + const createdAt = parsed.created_at || ""; + const retweetCount = parsed.metrics?.retweet_count ?? parsed.metrics?.retweets ?? 0; + const likeCount = parsed.metrics?.like_count ?? parsed.metrics?.likes ?? 0; + const replyCount = parsed.metrics?.reply_count ?? parsed.metrics?.replies ?? 0; + const mediaCount = parsed.media?.length ?? 0; + const threadCount = parsed.thread?.tweets?.length ?? 0; + const repliesCount = parsed.replies?.length ?? 0; + + const lines: string[] = []; + lines.push(`Author: ${author}`); + if (createdAt) lines.push(`Posted: ${createdAt}`); + lines.push(""); + lines.push(text); + if (retweetCount || likeCount || replyCount) { + lines.push(""); + lines.push(`Retweets: ${retweetCount} Likes: ${likeCount} Replies: ${replyCount}`); + } + if (mediaCount) lines.push(`Media: ${mediaCount} attachment(s)`); + if (threadCount) lines.push(`Thread: ${threadCount} tweets total`); + if (repliesCount) lines.push(`Replies fetched: ${repliesCount}`); + + return { + content: [ + { type: "text", text: lines.join("\n") }, + { type: "text", text: `\n--- raw ---\n${JSON.stringify(parsed, null, 2)}` }, + ], + details: { + author, + text: text.slice(0, 500), + url, + mode, + tweetCount: threadCount || repliesCount || 1, + }, + }; + } catch (e: any) { + const errMsg = e.stderr || e.message || String(e); + const hint = errMsg.includes("auth") + ? " Sign in to x.com in your browser and retry." + : ""; + + return { + content: [{ type: "text", text: `Failed to fetch tweet: ${errMsg}${hint}` }], + details: { error: errMsg, url, mode }, + }; + } + }, + }); +} diff --git a/plugins/pi-read-tweet/package.json b/plugins/pi-read-tweet/package.json new file mode 100644 index 0000000..8276805 --- /dev/null +++ b/plugins/pi-read-tweet/package.json @@ -0,0 +1,10 @@ +{ + "name": "pi-read-tweet", + "version": "1.0.0", + "description": "Read X/Twitter tweets using the local `bird` CLI — registered as a read_tweet tool for Pi Dev", + "main": "index.ts", + "type": "module", + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*" + } +} diff --git a/scripts/register-guards.sh b/scripts/register-guards.sh index 49d7a84..8302c0b 100755 --- a/scripts/register-guards.sh +++ b/scripts/register-guards.sh @@ -54,18 +54,26 @@ else echo "OpenCode: not detected (no ~/.config/opencode/opencode.json or .jsonc)" fi -# Pi Dev guard -PI_SOURCE="$FRAMEWORK_DIR/plugins/automaton-guard-pi" +# Pi Dev extensions +PI_EXTENSIONS=( + "$FRAMEWORK_DIR/plugins/automaton-guard-pi" + "$FRAMEWORK_DIR/plugins/pi-read-tweet" + "$FRAMEWORK_DIR/plugins/pi-automaton-context" + "$FRAMEWORK_DIR/plugins/pi-automaton-tools" +) if command -v pi &>/dev/null; then - INSTALLED=$(pi list 2>/dev/null | grep -c "automaton-guard-pi" || true) - if [ "$INSTALLED" -gt 0 ]; then - echo "Pi Dev: already installed" - else - echo "Pi Dev: installing guard extension..." - pi install "$PI_SOURCE" 2>&1 | sed 's/^/ /' - INSTALLED_PI=true - echo "Pi Dev: installed" - fi + for ext in "${PI_EXTENSIONS[@]}"; do + ext_name=$(basename "$ext") + INSTALLED=$(pi list 2>/dev/null | grep -c "$ext_name" || true) + if [ "$INSTALLED" -gt 0 ]; then + echo "Pi Dev: $ext_name already installed" + else + echo "Pi Dev: installing $ext_name..." + pi install "$ext" 2>&1 | sed 's/^/ /' + INSTALLED_PI=true + echo "Pi Dev: $ext_name installed" + fi + done # Offer pi-automaton startup wrapper PI_WRAPPER="$FRAMEWORK_DIR/scripts/pi-automaton.sh" if [ -f "$PI_WRAPPER" ]; then