142 lines
5.1 KiB
TypeScript
142 lines
5.1 KiB
TypeScript
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<BeforeAgentStartEventResult | undefined> => {
|
|
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<string, number>();
|
|
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")}`,
|
|
};
|
|
});
|
|
}
|