Rename Foundry handoffs to tasks (#239)

* Restore foundry onboarding stack

* Consolidate foundry rename

* Create foundry tasks without prompts

* Rename Foundry handoffs to tasks
This commit is contained in:
Nathan Flurry 2026-03-11 13:23:54 -07:00 committed by GitHub
parent d30cc0bcc8
commit d75e8c31d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
281 changed files with 9242 additions and 4356 deletions

View file

@ -0,0 +1,124 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export type NotifyUrgency = "low" | "normal" | "high";
export interface NotifyBackend {
name: string;
available(): Promise<boolean>;
send(title: string, body: string, urgency: NotifyUrgency): Promise<boolean>;
}
async function isOnPath(binary: string): Promise<boolean> {
try {
await execFileAsync("which", [binary]);
return true;
} catch {
return false;
}
}
export class OpenclawBackend implements NotifyBackend {
readonly name = "openclaw";
async available(): Promise<boolean> {
return isOnPath("openclaw");
}
async send(title: string, body: string, _urgency: NotifyUrgency): Promise<boolean> {
try {
await execFileAsync("openclaw", ["wake", "--title", title, "--body", body]);
return true;
} catch {
return false;
}
}
}
export class MacOsNotifyBackend implements NotifyBackend {
readonly name = "macos-osascript";
async available(): Promise<boolean> {
return process.platform === "darwin";
}
async send(title: string, body: string, _urgency: NotifyUrgency): Promise<boolean> {
try {
const escaped_body = body.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const escaped_title = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const script = `display notification "${escaped_body}" with title "${escaped_title}"`;
await execFileAsync("osascript", ["-e", script]);
return true;
} catch {
return false;
}
}
}
export class LinuxNotifySendBackend implements NotifyBackend {
readonly name = "linux-notify-send";
async available(): Promise<boolean> {
return isOnPath("notify-send");
}
async send(title: string, body: string, urgency: NotifyUrgency): Promise<boolean> {
const urgencyMap: Record<NotifyUrgency, string> = {
low: "low",
normal: "normal",
high: "critical",
};
try {
await execFileAsync("notify-send", ["-u", urgencyMap[urgency], title, body]);
return true;
} catch {
return false;
}
}
}
export class TerminalBellBackend implements NotifyBackend {
readonly name = "terminal";
async available(): Promise<boolean> {
return true;
}
async send(title: string, body: string, _urgency: NotifyUrgency): Promise<boolean> {
try {
process.stderr.write("\x07");
process.stderr.write(`[${title}] ${body}\n`);
return true;
} catch {
return false;
}
}
}
const backendFactories: Record<string, () => NotifyBackend> = {
openclaw: () => new OpenclawBackend(),
"macos-osascript": () => new MacOsNotifyBackend(),
"linux-notify-send": () => new LinuxNotifySendBackend(),
terminal: () => new TerminalBellBackend(),
};
export async function createBackends(configOrder: string[]): Promise<NotifyBackend[]> {
const backends: NotifyBackend[] = [];
for (const name of configOrder) {
const backendBuilder = backendFactories[name];
if (!backendBuilder) {
continue;
}
const backend = backendBuilder();
if (await backend.available()) {
backends.push(backend);
}
}
return backends;
}

View file

@ -0,0 +1,63 @@
import type { NotifyBackend, NotifyUrgency } from "./backends.js";
export type { NotifyUrgency } from "./backends.js";
export { createBackends } from "./backends.js";
export interface NotificationService {
notify(title: string, body: string, urgency: NotifyUrgency): Promise<void>;
agentIdle(branchName: string): Promise<void>;
agentError(branchName: string, error: string): Promise<void>;
ciPassed(branchName: string, prNumber: number): Promise<void>;
ciFailed(branchName: string, prNumber: number): Promise<void>;
prApproved(branchName: string, prNumber: number, reviewer: string): Promise<void>;
changesRequested(branchName: string, prNumber: number, reviewer: string): Promise<void>;
prMerged(branchName: string, prNumber: number): Promise<void>;
taskCreated(branchName: string): Promise<void>;
}
export function createNotificationService(backends: NotifyBackend[]): NotificationService {
async function notify(title: string, body: string, urgency: NotifyUrgency): Promise<void> {
for (const backend of backends) {
const sent = await backend.send(title, body, urgency);
if (sent) {
return;
}
}
}
return {
notify,
async agentIdle(branchName: string): Promise<void> {
await notify("Agent Idle", `Agent finished on ${branchName}`, "normal");
},
async agentError(branchName: string, error: string): Promise<void> {
await notify("Agent Error", `Agent error on ${branchName}: ${error}`, "high");
},
async ciPassed(branchName: string, prNumber: number): Promise<void> {
await notify("CI Passed", `CI passed on ${branchName} (PR #${prNumber})`, "low");
},
async ciFailed(branchName: string, prNumber: number): Promise<void> {
await notify("CI Failed", `CI failed on ${branchName} (PR #${prNumber})`, "high");
},
async prApproved(branchName: string, prNumber: number, reviewer: string): Promise<void> {
await notify("PR Approved", `PR #${prNumber} on ${branchName} approved by ${reviewer}`, "normal");
},
async changesRequested(branchName: string, prNumber: number, reviewer: string): Promise<void> {
await notify("Changes Requested", `Changes requested on PR #${prNumber} (${branchName}) by ${reviewer}`, "high");
},
async prMerged(branchName: string, prNumber: number): Promise<void> {
await notify("PR Merged", `PR #${prNumber} on ${branchName} merged`, "normal");
},
async taskCreated(branchName: string): Promise<void> {
await notify("Task Created", `New task on ${branchName}`, "low");
},
};
}

View file

@ -0,0 +1,43 @@
export type CiState = "running" | "pass" | "fail" | "unknown";
export type ReviewState = "approved" | "changes_requested" | "pending" | "none" | "unknown";
export interface PrStateTransition {
type: "ci_passed" | "ci_failed" | "pr_approved" | "changes_requested";
branchName: string;
prNumber: number;
reviewer?: string;
}
export class PrStateTracker {
private states: Map<string, { ci: CiState; review: ReviewState }>;
constructor() {
this.states = new Map();
}
update(repoId: string, branchName: string, prNumber: number, ci: CiState, review: ReviewState, reviewer?: string): PrStateTransition[] {
const key = `${repoId}:${branchName}`;
const prev = this.states.get(key);
const transitions: PrStateTransition[] = [];
if (prev) {
// CI transitions: only fire when moving from "running" to a terminal state
if (prev.ci === "running" && ci === "pass") {
transitions.push({ type: "ci_passed", branchName, prNumber });
} else if (prev.ci === "running" && ci === "fail") {
transitions.push({ type: "ci_failed", branchName, prNumber });
}
// Review transitions: only fire when moving from "pending" to a terminal state
if (prev.review === "pending" && review === "approved") {
transitions.push({ type: "pr_approved", branchName, prNumber, reviewer });
} else if (prev.review === "pending" && review === "changes_requested") {
transitions.push({ type: "changes_requested", branchName, prNumber, reviewer });
}
}
this.states.set(key, { ci, review });
return transitions;
}
}