Skip to content

Instantly share code, notes, and snippets.

@bradygaster
Created August 25, 2026 22:07
Show Gist options
  • Select an option

  • Save bradygaster/9a11cfb0dc050800deaa1e487adceb78 to your computer and use it in GitHub Desktop.

Select an option

Save bradygaster/9a11cfb0dc050800deaa1e487adceb78 to your computer and use it in GitHub Desktop.
Squad canvas extension for repository-specific team onboarding
{
"name": "squad-canvas",
"version": 1
}
import { createServer } from "node:http";
import { createHash } from "node:crypto";
import { execFile, spawn } from "node:child_process";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import {
CanvasError,
createCanvas,
joinSession,
} from "@github/copilot-sdk/extension";
import { renderHtml } from "./renderer.mjs";
const execFileAsync = promisify(execFile);
const servers = new Map();
let session;
const TOOL_PROPOSAL = "squad_canvas_publish_proposal";
const TOOL_MISSION = "squad_canvas_publish_mission_plan";
function slug(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "member";
}
function cleanText(value, maxLength = 12000) {
return String(value || "").trim().slice(0, maxLength);
}
function normalizeMembers(members) {
const seen = new Set();
return (Array.isArray(members) ? members : [])
.slice(0, 24)
.map((member, index) => {
let id = slug(member?.id || member?.name || `member-${index + 1}`);
while (seen.has(id)) id = `${id}-${index + 1}`;
seen.add(id);
return {
id,
name: cleanText(member?.name || `Member ${index + 1}`, 80),
role: cleanText(member?.role || "Specialist", 120),
rationale: cleanText(member?.rationale || "", 1200),
charter: cleanText(member?.charter || "", 12000),
lead: Boolean(member?.lead),
reviewer: Boolean(member?.reviewer),
charterPath: cleanText(member?.charterPath || "", 320),
status: cleanText(member?.status || "Active", 40),
};
});
}
function normalizeTasks(tasks, memberIds) {
const validMembers = new Set(memberIds);
return (Array.isArray(tasks) ? tasks : [])
.slice(0, 40)
.map((task, index) => ({
id: slug(task?.id || task?.title || `task-${index + 1}`),
title: cleanText(task?.title || `Task ${index + 1}`, 180),
description: cleanText(task?.description || "", 1200),
ownerId: validMembers.has(task?.ownerId) ? task.ownerId : "",
rationale: cleanText(task?.rationale || "", 1200),
}));
}
function hashKey(value) {
return createHash("sha256").update(String(value).toLowerCase()).digest("hex").slice(0, 20);
}
async function exists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function resolveRepoRoot(workingDirectory) {
if (!workingDirectory) return null;
try {
const { stdout } = await execFileAsync(
"git",
["-C", workingDirectory, "rev-parse", "--show-toplevel"],
{ windowsHide: true, maxBuffer: 1024 * 1024 },
);
return path.resolve(stdout.trim());
} catch {
return null;
}
}
async function readCharter(repoRoot, relativePath) {
if (!relativePath) return "";
const normalized = relativePath.replaceAll("`", "").replaceAll("/", path.sep);
const fullPath = path.resolve(repoRoot, normalized);
const relative = path.relative(repoRoot, fullPath);
if (relative.startsWith("..") || path.isAbsolute(relative) || !(await exists(fullPath))) return "";
return cleanText(await fs.readFile(fullPath, "utf8"), 12000);
}
async function parseTeam(repoRoot) {
const teamPath = path.join(repoRoot, ".squad", "team.md");
if (!(await exists(teamPath))) return [];
const content = await fs.readFile(teamPath, "utf8");
const lines = content.split(/\r?\n/);
const members = [];
let inMembers = false;
for (const line of lines) {
if (/^##\s+Members\b/i.test(line)) {
inMembers = true;
continue;
}
if (inMembers && /^##\s+/.test(line)) break;
if (!inMembers || !line.trim().startsWith("|")) continue;
const columns = line.split("|").slice(1, -1).map((column) => column.trim());
if (columns.length < 3) continue;
if (/^(name|[-:]+)$/i.test(columns[0])) continue;
const name = columns[0].replaceAll("**", "").trim();
const role = columns[1].replaceAll("**", "").trim();
const charterMatch = columns[2].match(/`([^`]+)`/);
const charterPath = charterMatch?.[1] || "";
if (!name || name.startsWith("@")) continue;
members.push({
id: slug(name),
name,
role,
rationale: `Configured in .squad/team.md as ${role}.`,
charter: await readCharter(repoRoot, charterPath),
charterPath,
lead: /\blead\b/i.test(role),
reviewer: /quality|review/i.test(role),
status: columns[3]?.replace(/[✅📋🔄🤖]/gu, "").trim() || "Active",
});
}
return normalizeMembers(members);
}
function storageRoot() {
if (session?.workspacePath) {
return path.join(session.workspacePath, "files", "squad-canvas");
}
const copilotHome = process.env.COPILOT_HOME || path.join(os.homedir(), ".copilot");
return path.join(copilotHome, "extensions", "squad-canvas", "artifacts");
}
async function statePathFor(repoRoot, workingDirectory) {
const root = storageRoot();
await fs.mkdir(root, { recursive: true });
return path.join(root, `${hashKey(repoRoot || workingDirectory || "no-repository")}.json`);
}
async function readPersistedState(statePath) {
if (!(await exists(statePath))) return null;
try {
const parsed = JSON.parse(await fs.readFile(statePath, "utf8"));
return parsed && parsed.version === 1 ? parsed : null;
} catch {
return null;
}
}
async function loadState(workingDirectory) {
const repoRoot = await resolveRepoRoot(workingDirectory);
const statePath = await statePathFor(repoRoot, workingDirectory);
const persisted = await readPersistedState(statePath);
if (!repoRoot) {
return {
statePath,
state: {
version: 1,
mode: "unavailable",
workingDirectory: workingDirectory || "",
repoRoot: "",
repoName: "",
message: "Open Squad Canvas from a Copilot project session.",
signals: [],
members: [],
mission: null,
operation: null,
pullRequest: null,
onboarding: { automation: null, cast: null, syncError: "" },
},
};
}
const actualMembers = await parseTeam(repoRoot);
const initialized = actualMembers.length > 0;
let members = initialized ? actualMembers : normalizeMembers(persisted?.members);
if (initialized && persisted?.members?.length) {
const drafts = new Map(persisted.members.map((member) => [member.id, member]));
members = actualMembers.map((member) => {
const draft = drafts.get(member.id);
return draft
? {
...member,
draftRole: draft.draftRole || draft.role || member.role,
draftCharter: draft.draftCharter || draft.charter || member.charter,
dirty: Boolean(draft.dirty),
}
: member;
});
}
return {
statePath,
state: {
version: 1,
mode: initialized ? "active" : "setup",
workingDirectory,
repoRoot,
repoName: path.basename(repoRoot),
message: "",
signals: Array.isArray(persisted?.signals) ? persisted.signals.slice(0, 12) : [],
summary: cleanText(persisted?.summary || "", 1800),
members,
mission: persisted?.mission || null,
operation: null,
pullRequest: persisted?.pullRequest || null,
onboarding: {
automation: persisted?.onboarding?.automation || null,
cast: persisted?.onboarding?.cast || null,
syncError: "",
},
},
};
}
async function persist(entry) {
await fs.mkdir(path.dirname(entry.statePath), { recursive: true });
const temporary = `${entry.statePath}.tmp`;
await fs.writeFile(temporary, `${JSON.stringify(entry.state, null, 2)}\n`, "utf8");
await fs.rename(temporary, entry.statePath);
}
function broadcast(entry) {
const payload = `event: state\ndata: ${JSON.stringify(entry.state)}\n\n`;
for (const client of entry.clients) client.write(payload);
}
async function updateState(entry, updater) {
updater(entry.state);
await persist(entry);
broadcast(entry);
}
function responseContent(response) {
return cleanText(response?.data?.content || "", 20000);
}
function pullRequestFrom(text) {
const content = String(text || "");
const fullUrl = content.match(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/i);
if (fullUrl) return fullUrl[0];
const shorthand = content.match(/\b([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#(\d+)\b/);
return shorthand ? `https://github.com/${shorthand[1]}/pull/${shorthand[2]}` : "";
}
function castingText(value, fallback = "") {
return cleanText(value || fallback, 12000).replace(/\/squad\b/gi, "Squad");
}
function castIssueBody(state) {
const signals = state.signals.length
? `\n## Repository signals\n\n${state.signals.map((signal) => `- ${castingText(signal)}`).join("\n")}\n`
: "";
const members = state.members.map((member) => {
const name = castingText(member.name, "Squad member").replace(/\r?\n/g, " ");
const role = castingText(member.draftRole || member.role, "Specialist").replace(/\r?\n/g, " ");
const charter = castingText(
member.draftCharter || member.charter,
`Own ${role} outcomes and collaborate with the rest of the Squad.`,
);
const designation = member.lead
? "Squad lead"
: member.reviewer ? "Independent reviewer" : "Specialist";
return `### ${name} — ${role}
**Designation:** ${designation}
**Why this role:** ${castingText(member.rationale, `Own the ${role} responsibility for this repository.`)}
#### Operating charter
${charter}`;
}).join("\n\n");
return `# Approved Squad
Create this repository-specific Squad exactly as reviewed. This issue is the source of truth for the roster, role boundaries, and operating charters.
## Repository context
${castingText(state.summary, `A tailored Squad for ${state.repoName}.`)}
${signals}
## Team specification
${members}
## Casting requirements
- Preserve these member names, roles, designations, and responsibility boundaries.
- Generate the standard Squad team, charter, routing, history, and agent files.
- Open a pull request for human review.
- Do not merge the pull request.
`;
}
async function runGhJson(args, cwd, input) {
return new Promise((resolve, reject) => {
const child = spawn("gh", args, {
cwd,
windowsHide: true,
stdio: ["pipe", "pipe", "pipe"],
});
const stdout = [];
const stderr = [];
let size = 0;
const collect = (chunks, chunk) => {
size += chunk.length;
if (size > 8 * 1024 * 1024) {
child.kill();
reject(new Error("GitHub CLI response exceeded 8 MB."));
return;
}
chunks.push(chunk);
};
child.stdout.on("data", (chunk) => collect(stdout, chunk));
child.stderr.on("data", (chunk) => collect(stderr, chunk));
child.on("error", (error) => reject(new Error(`Unable to start GitHub CLI: ${error.message}`)));
child.on("close", (code) => {
const output = Buffer.concat(stdout).toString("utf8").trim();
const detail = Buffer.concat(stderr).toString("utf8").trim();
if (code !== 0) {
reject(new Error(cleanText(detail || output || `GitHub CLI exited with code ${code}.`, 1600)));
return;
}
if (!output) {
resolve({});
return;
}
try {
resolve(JSON.parse(output));
} catch {
reject(new Error("GitHub CLI returned an invalid JSON response."));
}
});
child.stdin.on("error", (error) => reject(new Error(`Unable to send data to GitHub CLI: ${error.message}`)));
child.stdin.end(input === undefined ? "" : JSON.stringify(input));
});
}
async function createCastIssue(entry) {
if (entry.state.operation?.status === "running") return;
await updateState(entry, (state) => {
state.operation = {
kind: "cast-issue",
status: "running",
message: "Creating the cast issue and starting Squad…",
};
});
try {
const repository = await runGhJson(
["repo", "view", "--json", "nameWithOwner"],
entry.state.repoRoot,
);
const nameWithOwner = cleanText(repository.nameWithOwner, 300);
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(nameWithOwner)) {
throw new Error("GitHub CLI did not return a valid repository name.");
}
const issues = await runGhJson(
[
"issue", "list",
"--state", "all",
"--search", "\"Cast the Squad\" in:title",
"--json", "number,title,url,state",
"--limit", "100",
],
entry.state.repoRoot,
);
let issue = Array.isArray(issues)
? issues.find((candidate) => candidate.title === "Cast the Squad")
: null;
if (!issue) {
issue = await runGhJson(
["api", "--method", "POST", `repos/${nameWithOwner}/issues`, "--input", "-"],
entry.state.repoRoot,
{
title: "Cast the Squad",
body: castIssueBody(entry.state),
},
);
} else {
issue = await runGhJson(
["api", "--method", "PATCH", `repos/${nameWithOwner}/issues/${issue.number}`, "--input", "-"],
entry.state.repoRoot,
{
title: "Cast the Squad",
body: castIssueBody(entry.state),
state: "open",
},
);
}
const issueNumber = Number(issue.number);
const issueUrl = cleanText(issue.html_url || issue.url, 500);
if (!Number.isInteger(issueNumber) || issueNumber <= 0 || !issueUrl) {
throw new Error("GitHub did not return the created cast issue.");
}
const comments = await runGhJson(
["api", `repos/${nameWithOwner}/issues/${issueNumber}/comments?per_page=100`],
entry.state.repoRoot,
);
const castAlreadyStarted = Array.isArray(comments) &&
comments.some((comment) =>
String(comment?.body || "").trim() === "/squad cast" &&
Date.now() - Date.parse(comment?.created_at || 0) < 15 * 60 * 1000);
if (!castAlreadyStarted) {
await runGhJson(
[
"api", "--method", "POST",
`repos/${nameWithOwner}/issues/${issueNumber}/comments`,
"--input", "-",
],
entry.state.repoRoot,
{ body: "/squad cast" },
);
}
await updateState(entry, (state) => {
state.onboarding ||= { automation: null, cast: null, syncError: "" };
state.onboarding.cast = {
issueUrl,
issueNumber,
status: "triggered",
command: "/squad cast",
triggeredAt: new Date().toISOString(),
};
state.operation = {
kind: "cast-issue",
status: "complete",
message: "Cast issue created and /squad cast posted. Squad is preparing pull request 2 of 2.",
};
});
} catch (error) {
await updateState(entry, (state) => {
state.operation = {
kind: "cast-issue",
status: "error",
message: cleanText(error?.message || error, 1200),
};
});
}
}
function isAutomationPullRequest(pullRequest) {
const files = Array.isArray(pullRequest?.files) ? pullRequest.files : [];
const workflowFiles = files.filter((file) =>
/^\.github\/workflows\/squad(?:-implement-worker|-review)?(?:\.md|\.lock\.yml)$/i.test(file?.path || ""));
return /(?:squad.*automation|automation.*squad)/i.test(pullRequest?.title || "") ||
workflowFiles.length >= 2;
}
async function inspectAutomationPullRequest(repoRoot) {
try {
const { stdout } = await execFileAsync(
"gh",
["pr", "view", "--json", "url,state,mergedAt,title,files"],
{
cwd: repoRoot,
windowsHide: true,
maxBuffer: 4 * 1024 * 1024,
},
);
const pullRequest = JSON.parse(stdout);
if (!isAutomationPullRequest(pullRequest)) return null;
return {
url: cleanText(pullRequest.url, 500),
status: pullRequest.mergedAt || pullRequest.state === "MERGED"
? "merged"
: cleanText(pullRequest.state || "open", 40).toLowerCase(),
mergedAt: pullRequest.mergedAt || null,
title: cleanText(pullRequest.title, 240),
};
} catch (error) {
const detail = cleanText(`${error?.stderr || ""}\n${error?.message || error}`, 1600);
if (/no pull requests found|could not resolve to a pullrequest/i.test(detail)) return null;
throw new Error(`Unable to check the automation pull request: ${detail}`);
}
}
async function refreshRemoteState(entry, { force = false } = {}) {
if (!entry.state.repoRoot || entry.state.mode !== "setup") return;
if (!force && Date.now() - (entry.lastRemoteCheckAt || 0) < 10000) return;
if (entry.remoteCheckPromise) return entry.remoteCheckPromise;
entry.lastRemoteCheckAt = Date.now();
entry.remoteCheckPromise = (async () => {
try {
const automation = await inspectAutomationPullRequest(entry.state.repoRoot);
const previous = JSON.stringify(entry.state.onboarding?.automation || null);
const next = JSON.stringify(automation);
const hadError = Boolean(entry.state.onboarding?.syncError);
if (previous === next && !hadError) return;
await updateState(entry, (state) => {
state.onboarding ||= { automation: null, cast: null, syncError: "" };
state.onboarding.automation = automation;
state.onboarding.syncError = "";
if (!automation) return;
state.pullRequest = {
url: automation.url,
createdAt: state.pullRequest?.createdAt || new Date().toISOString(),
kind: "automation-pr",
status: automation.status,
mergedAt: automation.mergedAt,
};
state.operation = {
kind: "automation-pr",
status: "complete",
message: automation.status === "merged"
? "Automation pull request merged. Ready to cast your Squad."
: "Automation pull request created. Review and merge it on GitHub.",
};
});
} catch (error) {
const message = cleanText(error?.message || error, 1200);
if (entry.state.onboarding?.syncError === message) return;
await updateState(entry, (state) => {
state.onboarding ||= { automation: null, cast: null, syncError: "" };
state.onboarding.syncError = message;
});
} finally {
entry.remoteCheckPromise = null;
}
})();
return entry.remoteCheckPromise;
}
function proposalPrompt(entry) {
return `Analyze the repository at ${entry.state.repoRoot} without modifying it.
Your goal is to propose the smallest useful Squad for this repository. Infer responsibilities from the actual architecture, tests, documentation, workflows, and recurring ownership boundaries. Do not ask the user to assign hypothetical tasks during onboarding.
When complete, call the ${TOOL_PROPOSAL} tool exactly once with:
- instanceId: ${entry.instanceId}
- repositoryName
- summary: a concise repository-specific explanation
- signals: 3-8 concrete repository signals
- members: 3-8 proposed members, each with id, name, role, rationale, charter, lead, and reviewer
Every charter must be repository-specific and actionable. Include one lead and one independent reviewer. Do not edit files, create branches, or open pull requests during this analysis.`;
}
function missionPrompt(entry, goal) {
const team = entry.state.members.map(({ id, name, role }) => ({ id, name, role }));
return `Plan a Squad mission for the repository at ${entry.state.repoRoot}.
Goal:
${goal}
Authorized roster:
${JSON.stringify(team, null, 2)}
Inspect the repository as needed, but do not modify it. Decompose the goal into the smallest coherent set of tasks and assign each task to the member most likely to handle it well. The user should only need to override exceptional assignments.
When complete, call ${TOOL_MISSION} exactly once with:
- instanceId: ${entry.instanceId}
- goal
- summary
- tasks: id, title, description, ownerId, rationale
Use only ownerId values from the authorized roster.`;
}
function automationPullRequestPrompt(entry) {
return `Create the first of two Squad onboarding pull requests in ${entry.state.repoRoot}.
This pull request bootstraps repository automation only. The approved Squad proposal remains in the canvas and will be delivered later through a separate Cast pull request.
Work in the current Copilot project-session worktree.
Requirements:
1. Verify the GitHub Agentic Workflows extension is available. Install github/gh-aw only if it is missing.
2. Add the Squad dispatcher, implementation worker, and reviewer in this order:
gh aw add bradygaster/squad/workflows/squad.md@dev bradygaster/squad/workflows/squad-implement-worker.md@dev bradygaster/squad/workflows/squad-review.md@dev
3. If gh-aw reports a restricted-secret safe-update approval requirement, stop and surface the exact warning instead of approving it automatically.
4. Do not generate or modify .squad/**, .github/agents/squad.agent.md, or meet-the-squad.md in this pull request.
5. Confirm the diff is limited to the gh-aw bootstrap surface: .gitattributes, .github/workflows/**, and .github/skills/**.
6. Commit, push the project-session branch, and open a reviewable pull request titled to make clear that it installs Squad automation.
7. Never merge the pull request, change repository Actions settings, or bypass branch protection.
If npm access becomes necessary, first run:
npm config set registry "https://packagefeedproxy.microsoft.io/npm/"
Finish with the pull request URL on its own line.`;
}
function charterPullRequestPrompt(entry) {
const changes = entry.state.members
.filter((member) => member.dirty)
.map((member) => ({
id: member.id,
name: member.name,
charterPath: member.charterPath,
role: member.draftRole || member.role,
charter: member.draftCharter || member.charter,
}));
return `Apply the confirmed Squad charter changes in ${entry.state.repoRoot}.
Approved changes:
${JSON.stringify(changes, null, 2)}
Only edit the listed Squad member records and charter files. Preserve all other team configuration. Validate the resulting Squad state, commit, push the project-session branch, and open a reviewable pull request. Never merge it.
If npm access becomes necessary, first run:
npm config set registry "https://packagefeedproxy.microsoft.io/npm/"
Finish with the pull request URL on its own line.`;
}
function executeMissionPrompt(entry) {
const mission = entry.state.mission;
return `Execute this confirmed Squad mission in ${entry.state.repoRoot}.
Goal:
${mission.goal}
Approved plan:
${JSON.stringify(mission.tasks, null, 2)}
Execution rules:
1. The current project session is the sole writer to the candidate branch.
2. Use separate agent contexts for specialist investigation and evidence when useful.
3. Follow the approved ownership plan unless a repository fact makes it impossible; surface that conflict instead of silently rerouting.
4. Synthesize one candidate diff, run targeted validation, then perform an independent review of the exact candidate.
5. Address blocking review findings, revalidate, commit, push, and open a pull request.
6. Never merge the pull request or bypass branch protection.
If npm access becomes necessary, first run:
npm config set registry "https://packagefeedproxy.microsoft.io/npm/"
Finish with the pull request URL on its own line.`;
}
async function runAgentOperation(entry, kind, prompt) {
if (entry.state.operation?.status === "running") return;
await updateState(entry, (state) => {
state.operation = { kind, status: "running", message: "Copilot is working…" };
});
try {
const response = await session.sendAndWait({ prompt }, 15 * 60 * 1000);
const content = responseContent(response);
if (kind === "analyze" && entry.state.members.length > 0) return;
if (kind === "plan" && entry.state.mission?.tasks?.length > 0) return;
const prUrl = pullRequestFrom(content);
await updateState(entry, (state) => {
state.operation = {
kind,
status: prUrl ? "complete" : "error",
message: prUrl
? "Pull request created."
: "Copilot finished without returning the expected structured result.",
};
if (prUrl) {
state.pullRequest = { url: prUrl, createdAt: new Date().toISOString(), kind, status: "open" };
if (kind === "automation-pr") {
state.onboarding ||= { automation: null, cast: null, syncError: "" };
state.onboarding.automation = {
url: prUrl,
status: "open",
mergedAt: null,
title: "",
};
}
}
});
} catch (error) {
await updateState(entry, (state) => {
state.operation = {
kind,
status: "error",
message: cleanText(error?.message || error, 1200),
};
});
}
}
async function parseBody(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > 1024 * 1024) throw new Error("Request body exceeds 1 MB.");
chunks.push(chunk);
}
if (chunks.length === 0) return {};
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function sendJson(res, status, body) {
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
});
res.end(JSON.stringify(body));
}
async function handleRequest(entry, req, res) {
const url = new URL(req.url || "/", "http://127.0.0.1");
if (req.method === "GET" && url.pathname === "/favicon.ico") {
res.writeHead(204);
res.end();
return;
}
if (req.method === "GET" && url.pathname === "/") {
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
});
res.end(renderHtml());
return;
}
if (req.method === "GET" && url.pathname === "/api/state") {
await refreshRemoteState(entry);
sendJson(res, 200, entry.state);
return;
}
if (req.method === "GET" && url.pathname === "/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
res.write(`event: state\ndata: ${JSON.stringify(entry.state)}\n\n`);
entry.clients.add(res);
req.on("close", () => entry.clients.delete(res));
return;
}
if (req.method !== "POST") {
sendJson(res, 404, { error: "Not found." });
return;
}
const body = await parseBody(req);
switch (url.pathname) {
case "/api/analyze": {
if (entry.state.mode !== "setup") {
sendJson(res, 409, { error: "This repository already has a Squad." });
return;
}
void runAgentOperation(entry, "analyze", proposalPrompt(entry));
sendJson(res, 202, { accepted: true });
return;
}
case "/api/member": {
const member = entry.state.members.find((candidate) => candidate.id === body.id);
if (!member) {
sendJson(res, 404, { error: "Member not found." });
return;
}
await updateState(entry, () => {
member.draftRole = cleanText(body.role || member.role, 120);
member.draftCharter = cleanText(body.charter || member.charter, 12000);
member.dirty = member.draftRole !== member.role || member.draftCharter !== member.charter;
});
sendJson(res, 200, entry.state);
return;
}
case "/api/create-automation-pr": {
if (entry.state.mode !== "setup" || entry.state.members.length === 0) {
sendJson(res, 409, { error: "Analyze and confirm the team first." });
return;
}
void runAgentOperation(entry, "automation-pr", automationPullRequestPrompt(entry));
sendJson(res, 202, { accepted: true });
return;
}
case "/api/create-cast-issue": {
if (
entry.state.mode !== "setup" ||
entry.state.members.length === 0 ||
entry.state.onboarding?.automation?.status !== "merged"
) {
sendJson(res, 409, { error: "Merge the automation pull request before casting the Squad." });
return;
}
void createCastIssue(entry);
sendJson(res, 202, { accepted: true });
return;
}
case "/api/create-charter-pr": {
if (!entry.state.members.some((member) => member.dirty)) {
sendJson(res, 409, { error: "No charter changes are pending." });
return;
}
void runAgentOperation(entry, "charter-pr", charterPullRequestPrompt(entry));
sendJson(res, 202, { accepted: true });
return;
}
case "/api/plan-mission": {
const goal = cleanText(body.goal, 4000);
if (entry.state.mode !== "active" || !goal) {
sendJson(res, 400, { error: "A goal is required in an active Squad repository." });
return;
}
await updateState(entry, (state) => {
state.mission = { goal, summary: "", tasks: [], status: "planning" };
});
void runAgentOperation(entry, "plan", missionPrompt(entry, goal));
sendJson(res, 202, { accepted: true });
return;
}
case "/api/task-owner": {
const task = entry.state.mission?.tasks?.find((candidate) => candidate.id === body.taskId);
const owner = entry.state.members.find((member) => member.id === body.ownerId);
if (!task || !owner) {
sendJson(res, 404, { error: "Task or owner not found." });
return;
}
await updateState(entry, () => {
task.ownerId = owner.id;
task.overridden = true;
});
sendJson(res, 200, entry.state);
return;
}
case "/api/start-mission": {
if (!entry.state.mission?.tasks?.length) {
sendJson(res, 409, { error: "Plan the mission first." });
return;
}
void runAgentOperation(entry, "mission-pr", executeMissionPrompt(entry));
sendJson(res, 202, { accepted: true });
return;
}
case "/api/clear-mission": {
await updateState(entry, (state) => {
state.mission = null;
state.operation = null;
state.pullRequest = null;
});
sendJson(res, 200, entry.state);
return;
}
default:
sendJson(res, 404, { error: "Not found." });
}
}
async function startServer(ctx) {
const workingDirectory =
cleanText(ctx.input?.workingDirectory || ctx.session?.workingDirectory || "", 1000);
const { state, statePath } = await loadState(workingDirectory);
const entry = {
instanceId: ctx.instanceId,
state,
statePath,
clients: new Set(),
server: null,
url: "",
lastRemoteCheckAt: 0,
remoteCheckPromise: null,
};
const server = createServer((req, res) => {
handleRequest(entry, req, res).catch((error) => {
sendJson(res, 500, { error: cleanText(error?.message || error, 1200) });
});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
entry.server = server;
entry.url = `http://127.0.0.1:${port}/`;
servers.set(ctx.instanceId, entry);
return entry;
}
async function refreshEntry(entry) {
const refreshed = await loadState(entry.state.workingDirectory);
entry.state = refreshed.state;
entry.statePath = refreshed.statePath;
await persist(entry);
broadcast(entry);
await refreshRemoteState(entry, { force: true });
}
const canvas = createCanvas({
id: "squad",
displayName: "Squad",
description: "Build a repository-specific Squad, edit member charters, and direct reviewed work into pull requests.",
inputSchema: {
type: "object",
properties: {
workingDirectory: {
type: "string",
description: "Optional repository working directory; defaults to the active project session.",
},
},
additionalProperties: false,
},
actions: [
{
name: "get_state",
description: "Return the current repository, team, mission, and pull-request state.",
handler: async (ctx) => {
const entry = servers.get(ctx.instanceId);
if (!entry) throw new CanvasError("squad_not_open", "The Squad canvas is not open.");
await refreshRemoteState(entry, { force: true });
return entry.state;
},
},
{
name: "refresh",
description: "Reload Squad configuration from the current repository.",
handler: async (ctx) => {
const entry = servers.get(ctx.instanceId);
if (!entry) throw new CanvasError("squad_not_open", "The Squad canvas is not open.");
await refreshEntry(entry);
return entry.state;
},
},
],
open: async (ctx) => {
let entry = servers.get(ctx.instanceId);
if (!entry) entry = await startServer(ctx);
return {
title: entry.state.repoName ? `Squad · ${entry.state.repoName}` : "Squad",
status: entry.state.mode === "active" ? "Active" : "Setup",
url: entry.url,
};
},
onClose: async (ctx) => {
const entry = servers.get(ctx.instanceId);
if (!entry) return;
servers.delete(ctx.instanceId);
for (const client of entry.clients) client.end();
await new Promise((resolve) => entry.server.close(() => resolve()));
},
});
session = await joinSession({
canvases: [canvas],
tools: [
{
name: TOOL_PROPOSAL,
description: "Publish a repository analysis and proposed Squad to an open Squad canvas.",
parameters: {
type: "object",
properties: {
instanceId: { type: "string" },
repositoryName: { type: "string" },
summary: { type: "string" },
signals: { type: "array", items: { type: "string" }, maxItems: 12 },
members: {
type: "array",
minItems: 2,
maxItems: 24,
items: {
type: "object",
properties: {
id: { type: "string" },
name: { type: "string" },
role: { type: "string" },
rationale: { type: "string" },
charter: { type: "string" },
lead: { type: "boolean" },
reviewer: { type: "boolean" },
},
required: ["name", "role", "rationale", "charter"],
},
},
},
required: ["instanceId", "repositoryName", "summary", "signals", "members"],
},
handler: async (args) => {
const entry = servers.get(args.instanceId);
if (!entry) return { textResultForLlm: "Squad canvas instance not found.", resultType: "failure" };
const members = normalizeMembers(args.members);
if (!members.some((member) => member.lead) || !members.some((member) => member.reviewer)) {
return {
textResultForLlm: "Proposal must include one lead and one independent reviewer.",
resultType: "failure",
};
}
await updateState(entry, (state) => {
state.summary = cleanText(args.summary, 1800);
state.signals = (args.signals || []).map((signal) => cleanText(signal, 240)).filter(Boolean);
state.members = members;
state.operation = { kind: "analyze", status: "complete", message: "Team proposal ready." };
});
return "Squad proposal published to the canvas.";
},
},
{
name: TOOL_MISSION,
description: "Publish a Squad-generated mission plan and proposed task ownership to an open Squad canvas.",
parameters: {
type: "object",
properties: {
instanceId: { type: "string" },
goal: { type: "string" },
summary: { type: "string" },
tasks: {
type: "array",
minItems: 1,
maxItems: 40,
items: {
type: "object",
properties: {
id: { type: "string" },
title: { type: "string" },
description: { type: "string" },
ownerId: { type: "string" },
rationale: { type: "string" },
},
required: ["title", "ownerId", "rationale"],
},
},
},
required: ["instanceId", "goal", "summary", "tasks"],
},
handler: async (args) => {
const entry = servers.get(args.instanceId);
if (!entry) return { textResultForLlm: "Squad canvas instance not found.", resultType: "failure" };
const tasks = normalizeTasks(args.tasks, entry.state.members.map((member) => member.id));
if (tasks.some((task) => !task.ownerId)) {
return {
textResultForLlm: "Every mission task must use an ownerId from the authorized roster.",
resultType: "failure",
};
}
await updateState(entry, (state) => {
state.mission = {
goal: cleanText(args.goal, 4000),
summary: cleanText(args.summary, 1800),
status: "ready",
tasks,
};
state.operation = { kind: "plan", status: "complete", message: "Mission plan ready." };
});
return "Mission plan published to the Squad canvas.";
},
},
],
});
export function renderHtml() {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Squad</title>
<style>
:root {
--bg: var(--background-color-default, #ffffff);
--surface: var(--background-color-default, #ffffff);
--soft: color-mix(in srgb, var(--text-color-default, #1f2328) 5%, var(--background-color-default, #ffffff));
--soft-strong: color-mix(in srgb, var(--text-color-default, #1f2328) 10%, var(--background-color-default, #ffffff));
--border: var(--border-color-default, #d0d7de);
--border-strong: color-mix(in srgb, var(--text-color-default, #1f2328) 42%, var(--background-color-default, #ffffff));
--text: var(--text-color-default, #1f2328);
--muted: var(--text-color-muted, #656d76);
--focus: var(--color-focus-outline, #0969da);
--accent: var(--true-color-red, #b4234d);
--accent-soft: var(--true-color-red-muted, color-mix(in srgb, var(--accent) 12%, var(--bg)));
--success: var(--true-color-green, #1a7f37);
--warning: var(--true-color-yellow, #9a6700);
--danger: var(--true-color-red, #cf222e);
--sans: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
--mono: var(--font-mono, "SFMono-Regular", Consolas, monospace);
}
* { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: var(--text-body-medium, 14px);
line-height: var(--leading-body-medium, 20px);
}
button, input, textarea, select { font: inherit; }
button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, a:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.shell { min-height: 100vh; }
.topbar {
min-height: 58px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.brand, .repo, .actions, .member-heading, .status-line, .pr-result {
display: flex;
align-items: center;
}
.brand { gap: 9px; font-weight: var(--font-weight-semibold, 600); }
.mark {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 8px;
background: var(--accent);
color: var(--color-white, #ffffff);
font-weight: 800;
}
.repo { min-width: 0; gap: 8px; color: var(--muted); font-size: var(--text-body-small, 12px); }
.repo strong {
overflow: hidden;
color: var(--text);
text-overflow: ellipsis;
white-space: nowrap;
}
.mode {
padding: 3px 7px;
border: 1px solid var(--border);
border-radius: 999px;
white-space: nowrap;
}
main {
width: min(1120px, calc(100% - 32px));
margin: 0 auto;
padding: 38px 0 64px;
}
h1 {
max-width: 760px;
margin: 0;
font-family: var(--font-sans-display, var(--sans));
font-size: clamp(32px, 5vw, var(--text-display-medium, 48px));
line-height: 1.05;
letter-spacing: -.035em;
text-wrap: balance;
}
h2, h3 { margin: 0; font-weight: var(--font-weight-semibold, 600); }
h2 { font-size: var(--text-title-medium, 20px); }
h3 { font-size: var(--text-body-large, 15px); }
.lede {
max-width: 700px;
margin: 16px 0 0;
color: var(--muted);
font-size: var(--text-body-large, 16px);
line-height: 1.55;
}
.hero-actions { margin-top: 24px; }
.button {
min-height: 38px;
padding: 0 13px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
cursor: pointer;
font-weight: var(--font-weight-semibold, 600);
}
.button:hover { border-color: var(--border-strong); }
.button.primary {
border-color: var(--accent);
background: var(--accent);
color: var(--color-white, #ffffff);
}
.button.primary:hover { filter: brightness(.94); }
.button.danger { border-color: var(--danger); color: var(--danger); }
.button:disabled { cursor: wait; opacity: .62; }
.actions { flex-wrap: wrap; gap: 8px; }
.operation {
display: flex;
align-items: flex-start;
gap: 11px;
margin: 24px 0;
padding: 13px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--soft);
color: var(--muted);
}
.operation strong { color: var(--text); }
.operation.error { border-color: var(--danger); }
.spinner {
width: 16px;
height: 16px;
flex: 0 0 auto;
margin-top: 2px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.evidence {
overflow: hidden;
max-width: 900px;
margin-top: 26px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
}
.evidence-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 16px 18px;
background: var(--soft);
}
.evidence-header h2 { font-size: var(--text-body-large, 15px); }
.evidence-header p {
max-width: 68ch;
margin: 4px 0 0;
color: var(--muted);
font-size: var(--text-body-small, 12px);
}
.evidence-count {
flex: 0 0 auto;
color: var(--muted);
font-size: var(--text-body-small, 12px);
font-weight: var(--font-weight-semibold, 600);
white-space: nowrap;
}
.evidence-list {
margin: 0;
padding: 0;
list-style: none;
}
.evidence-item {
display: grid;
grid-template-columns: 10px minmax(0, 1fr);
gap: 11px;
align-items: start;
padding: 12px 18px;
border-top: 1px solid var(--border);
}
.evidence-dot {
width: 7px;
height: 7px;
margin-top: 6px;
border-radius: 50%;
background: var(--accent);
}
.evidence-item p { margin: 0; color: var(--text); }
.evidence details { border-top: 1px solid var(--border); }
.evidence details .evidence-item:first-child { border-top: 0; }
.evidence summary {
padding: 11px 18px;
color: var(--focus);
cursor: pointer;
font-weight: var(--font-weight-semibold, 600);
list-style-position: inside;
}
.evidence summary:hover { text-decoration: underline; }
.evidence-prompt {
display: flex;
gap: 7px;
padding: 13px 18px;
border-top: 1px solid var(--border);
background: var(--accent-soft);
font-size: var(--text-body-small, 12px);
}
.evidence-prompt span { color: var(--text); }
.onboarding {
overflow: hidden;
margin-bottom: 34px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
}
.onboarding-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 16px 18px;
background: var(--soft);
}
.onboarding-header h2 { font-size: var(--text-body-large, 15px); }
.onboarding-header p {
max-width: 68ch;
margin: 4px 0 0;
color: var(--muted);
font-size: var(--text-body-small, 12px);
}
.onboarding-count {
flex: 0 0 auto;
color: var(--muted);
font-size: var(--text-body-small, 12px);
font-weight: var(--font-weight-semibold, 600);
white-space: nowrap;
}
.progress-track {
height: 3px;
background: var(--soft-strong);
}
.progress-fill {
width: 100%;
height: 100%;
background: var(--accent);
transform-origin: left center;
transition: transform 260ms cubic-bezier(.22, 1, .36, 1);
}
.onboarding-steps {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.onboarding-step {
min-width: 0;
padding: 15px 14px 16px;
border-right: 1px solid var(--border);
}
.onboarding-step:last-child { border-right: 0; }
.onboarding-step.current { background: var(--accent-soft); }
.step-heading {
display: flex;
align-items: center;
gap: 8px;
}
.step-marker {
width: 22px;
height: 22px;
flex: 0 0 auto;
display: grid;
place-items: center;
border: 1px solid var(--border-strong);
border-radius: 50%;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.onboarding-step.done .step-marker {
border-color: var(--success);
background: var(--success);
color: var(--color-white, #ffffff);
}
.onboarding-step.current .step-marker {
border-color: var(--accent);
background: var(--accent);
color: var(--color-white, #ffffff);
}
.step-heading strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.step-copy {
margin: 7px 0 0 30px;
color: var(--muted);
font-size: 11px;
line-height: 1.4;
}
.onboarding-step.current .step-copy { color: var(--text); }
.layout {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(280px, .75fr);
gap: 32px;
margin-top: 32px;
align-items: start;
}
.section-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.section-heading p { margin: 4px 0 0; color: var(--muted); font-size: var(--text-body-small, 12px); }
.member-list {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
}
.member {
width: 100%;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 14px;
border: 0;
border-bottom: 1px solid var(--border);
background: var(--surface);
color: var(--text);
text-align: left;
cursor: pointer;
}
.member:last-child { border-bottom: 0; }
.member:hover { background: var(--soft); }
.member.selected { background: var(--accent-soft); }
.avatar {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 9px;
background: var(--soft-strong);
color: var(--accent);
font-weight: 750;
font-size: var(--text-body-small, 12px);
}
.member-heading { gap: 7px; min-width: 0; }
.member-heading strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
padding: 2px 6px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--muted);
font-size: 10px;
white-space: nowrap;
}
.member-role {
overflow: hidden;
margin-top: 2px;
color: var(--muted);
font-size: var(--text-body-small, 12px);
text-overflow: ellipsis;
white-space: nowrap;
}
.arrow { color: var(--muted); }
.panel {
padding: 18px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
}
.panel.sticky { position: sticky; top: 18px; }
.panel-copy { margin: 6px 0 16px; color: var(--muted); font-size: var(--text-body-small, 12px); }
.field { margin-top: 14px; }
.field label {
display: block;
margin-bottom: 5px;
font-size: var(--text-body-small, 12px);
font-weight: var(--font-weight-semibold, 600);
}
input, textarea, select {
width: 100%;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
}
input, select { height: 38px; padding: 0 10px; }
textarea { min-height: 120px; padding: 9px 10px; resize: vertical; line-height: 1.45; }
textarea.goal { min-height: 112px; }
.help { margin: 5px 0 0; color: var(--muted); font-size: 11px; }
.dirty {
margin-top: 12px;
padding: 9px 10px;
border-radius: 8px;
background: var(--accent-soft);
font-size: var(--text-body-small, 12px);
}
.summary {
margin: 24px 0 0;
padding: 18px 0 0;
border-top: 1px solid var(--border);
color: var(--muted);
}
.summary strong { color: var(--text); }
.confirmation {
margin-top: 14px;
padding: 13px;
border: 1px solid var(--accent);
border-radius: 10px;
background: var(--accent-soft);
}
.confirmation p { margin: 0 0 11px; }
.pr-result {
gap: 10px;
margin-top: 18px;
padding: 13px;
border: 1px solid var(--success);
border-radius: 10px;
}
.pr-result a { color: var(--focus); font-weight: var(--font-weight-semibold, 600); }
.next-action {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 22px;
align-items: center;
margin-top: 22px;
padding: 22px;
border: 1px solid var(--accent);
border-radius: 12px;
background: var(--accent-soft);
}
.next-action h2 { font-size: var(--text-title-large, 24px); }
.next-action p {
max-width: 68ch;
margin: 6px 0 0;
color: var(--text);
}
.next-action .button { min-height: 44px; padding-inline: 18px; }
.cast-result {
display: flex;
align-items: flex-start;
gap: 11px;
margin-top: 22px;
padding: 16px;
border: 1px solid var(--success);
border-radius: 10px;
background: color-mix(in srgb, var(--success) 7%, var(--bg));
}
.cast-result strong { color: var(--text); }
.cast-result p { margin: 3px 0 7px; color: var(--muted); }
.cast-result a { color: var(--focus); font-weight: var(--font-weight-semibold, 600); }
.mission-intro {
margin-top: 30px;
padding: 22px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
}
.mission-intro h2 { margin-bottom: 7px; }
.mission-intro > p { max-width: 720px; margin: 0 0 17px; color: var(--muted); }
.task-list { display: grid; gap: 9px; margin-top: 16px; }
.task {
display: grid;
grid-template-columns: minmax(0, 1fr) 210px;
gap: 16px;
padding: 13px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
}
.task p { margin: 4px 0 0; color: var(--muted); font-size: var(--text-body-small, 12px); }
.task select { align-self: center; }
.override { margin-top: 5px; color: var(--warning); font-size: 11px; }
.topology {
margin-top: 18px;
padding: 18px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--soft);
}
.topology-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.topology-node {
width: 156px;
padding: 10px;
border: 1px solid var(--border-strong);
border-radius: 9px;
background: var(--surface);
text-align: center;
}
.topology-node span { display: block; margin-top: 3px; color: var(--muted); font-size: 11px; }
.connector { margin: 8px 0; color: var(--muted); text-align: center; }
.empty {
padding: 56px 24px;
border: 1px dashed var(--border-strong);
border-radius: 12px;
text-align: center;
}
.empty h1 { margin: 0 auto; }
.empty p { max-width: 620px; margin: 14px auto 0; color: var(--muted); }
.error-text { color: var(--danger); }
@media (max-width: 800px) {
.topbar { align-items: flex-start; flex-direction: column; }
main { width: min(100% - 24px, 680px); padding-top: 26px; }
.onboarding-header { align-items: flex-start; }
.onboarding-steps { grid-template-columns: 1fr; }
.onboarding-step {
display: grid;
grid-template-columns: 1fr;
padding: 12px 14px;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.onboarding-step:last-child { border-bottom: 0; }
.step-copy { margin-top: 3px; }
.layout { grid-template-columns: 1fr; gap: 20px; }
.panel.sticky { position: static; }
.task { grid-template-columns: 1fr; }
.next-action { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}
</style>
</head>
<body>
<div class="shell">
<header class="topbar">
<div class="brand"><span class="mark">S</span><span>Squad</span></div>
<div class="repo" id="repo-header"></div>
</header>
<main id="app" aria-live="polite"></main>
</div>
<script>
let state = null;
let selectedMemberId = "";
let confirmSetup = false;
let confirmCast = false;
let confirmMission = false;
let confirmCharters = false;
const app = document.getElementById("app");
const repoHeader = document.getElementById("repo-header");
function esc(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function initials(name) {
return String(name || "S").split(/\\s+/).slice(0, 2).map(part => part[0]).join("").toUpperCase();
}
async function post(url, body = {}) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "Request failed.");
return result;
}
function operationHtml() {
if (!state?.operation) return "";
const operation = state.operation;
const running = operation.status === "running";
const error = operation.status === "error";
return \`
<div class="operation \${error ? "error" : ""}">
\${running ? '<span class="spinner" aria-hidden="true"></span>' : ""}
<div><strong>\${running ? "Working" : error ? "Needs attention" : "Complete"}</strong><br>\${esc(operation.message)}</div>
</div>\`;
}
function prHtml() {
if (!state?.pullRequest?.url) return "";
const automation = state.pullRequest.kind === "automation-pr";
const automationMerged = automation &&
(state.pullRequest.status === "merged" || state.onboarding?.automation?.status === "merged");
return \`
<div class="pr-result">
<span aria-hidden="true">✓</span>
<div>
<strong>\${automation
? automationMerged ? "Automation PR merged · 1 of 2 complete" : "Automation PR created · 1 of 2"
: "Pull request created"}</strong><br>
\${automation
? automationMerged
? "Next: create the cast issue below to start pull request 2.<br>"
: "Review and merge the repository bootstrap. This canvas checks GitHub automatically.<br>"
: ""}
<a href="\${esc(state.pullRequest.url)}" target="_blank" rel="noreferrer">Open on GitHub ↗</a>
</div>
</div>\`;
}
function castActionHtml() {
const automationMerged = state?.onboarding?.automation?.status === "merged";
if (!automationMerged) return "";
const cast = state?.onboarding?.cast;
if (cast?.issueUrl) {
return \`
<div class="cast-result">
<span aria-hidden="true">✓</span>
<div>
<strong>Cast issue created · Squad started</strong>
<p>The approved roster and charters are in the issue. The <code>/squad cast</code> comment is posted; Squad will open pull request 2 of 2.</p>
<a href="\${esc(cast.issueUrl)}" target="_blank" rel="noreferrer">Open Cast the Squad #\${esc(cast.issueNumber)} ↗</a>
</div>
</div>\`;
}
const running = state?.operation?.kind === "cast-issue" && state.operation.status === "running";
return \`
<section class="next-action" aria-labelledby="cast-action-title">
<div>
<h2 id="cast-action-title">Cast the team you approved.</h2>
<p>Create one issue containing this \${state.members.length}-member roster and every operating charter, then post <code>/squad cast</code> to start pull request 2 of 2.</p>
</div>
<button class="button primary" data-action="confirm-cast" type="button" \${running ? "disabled" : ""}>\${running ? "Creating issue…" : "Create cast issue"}</button>
</section>
\${confirmCast ? \`
<div class="confirmation">
<p><strong>Create “Cast the Squad” and start casting?</strong><br>The issue will contain the approved team as Markdown. The canvas will then post <code>/squad cast</code>, and Squad will create pull request 2 of 2 without merging it.</p>
<div class="actions">
<button class="button" data-action="cancel-confirm" type="button">Cancel</button>
<button class="button primary" data-action="create-cast-issue" type="button">Create issue and start Squad</button>
</div>
</div>\` : ""}\`;
}
function updateHeader() {
if (!state?.repoName) {
repoHeader.innerHTML = '<span class="mode">No project</span>';
return;
}
const progress = onboardingSnapshot();
const mode = state.mode === "active" ? "Squad active" : \`Onboarding · \${progress.current} of \${progress.steps.length}\`;
repoHeader.innerHTML = \`<strong>\${esc(state.repoName)}</strong><span class="mode">\${mode}</span>\`;
}
function memberBadge(member) {
if (member.lead) return '<span class="badge">Lead</span>';
if (member.reviewer) return '<span class="badge">Reviewer</span>';
return "";
}
function memberListHtml() {
return \`
<div class="member-list">
\${state.members.map(member => \`
<button class="member \${selectedMemberId === member.id ? "selected" : ""}" data-action="select-member" data-id="\${esc(member.id)}" type="button">
<span class="avatar">\${esc(initials(member.name))}</span>
<span>
<span class="member-heading"><strong>\${esc(member.name)}</strong>\${memberBadge(member)}</span>
<span class="member-role">\${esc(member.draftRole || member.role)}</span>
</span>
<span class="arrow" aria-hidden="true">›</span>
</button>
\`).join("")}
</div>\`;
}
function memberEditorHtml() {
const member = state.members.find(item => item.id === selectedMemberId) || state.members[0];
if (!member) return '<div class="panel"><p>No member selected.</p></div>';
selectedMemberId = member.id;
return \`
<aside class="panel sticky">
<h2>\${esc(member.name)}</h2>
<p class="panel-copy">\${esc(member.rationale || "Review and refine this member’s operating charter.")}</p>
<form data-form="member" data-id="\${esc(member.id)}">
<div class="field">
<label for="role-field">Role</label>
<input id="role-field" name="role" value="\${esc(member.draftRole || member.role)}">
</div>
<div class="field">
<label for="charter-field">Operating charter</label>
<textarea id="charter-field" name="charter">\${esc(member.draftCharter || member.charter)}</textarea>
<p class="help">Saved as a draft until you explicitly create a pull request.</p>
</div>
<div class="actions" style="margin-top:14px">
<button class="button primary" type="submit">Save draft</button>
</div>
</form>
\${member.dirty ? '<div class="dirty">This member has unapplied charter changes.</div>' : ""}
</aside>\`;
}
function onboardingSnapshot() {
const hasProposal = Boolean(state?.members?.length);
const automationMerged = state?.onboarding?.automation?.status === "merged";
const setupStarted =
(state?.operation?.kind === "automation-pr" && state?.operation?.status === "running") ||
state?.pullRequest?.kind === "automation-pr" ||
(state?.operation?.kind === "setup-pr" && state?.operation?.status === "running") ||
state?.pullRequest?.kind === "setup-pr";
const active = state?.mode === "active";
const current = active ? 6 : automationMerged ? 5 : setupStarted ? 4 : hasProposal ? 3 : 2;
const steps = [
{
title: "Repository connected",
description: "Use the active project-session worktree."
},
{
title: "Repository analyzed",
description: "Inspect code, tests, docs, and ownership signals."
},
{
title: "Review team",
description: "Shape roles and charters—no task assignment required."
},
{
title: "Install automation",
description: "Pull request 1 of 2 · add gh-aw and Squad workflows."
},
{
title: "Cast your Squad",
description: "Pull request 2 of 2 · review and merge the generated team."
},
{
title: "Squad ready",
description: "Start real missions through issues and reviewed pull requests."
}
];
return {
active,
current,
steps,
percent: active ? 100 : ((current - 1) / (steps.length - 1)) * 100
};
}
function onboardingProgressHtml() {
const progress = onboardingSnapshot();
const currentStep = progress.steps[progress.current - 1];
return \`
<nav class="onboarding" aria-label="Squad onboarding progress">
<div class="onboarding-header">
<div>
<h2>Repository onboarding</h2>
<p>\${progress.active
? "Setup is complete. This repository now owns its Squad configuration."
: \`Current step: \${esc(currentStep.title)}. \${esc(currentStep.description)}\`}</p>
</div>
<span class="onboarding-count">\${progress.active ? "Complete" : \`Step \${progress.current} of \${progress.steps.length}\`}</span>
</div>
<div class="progress-track" role="progressbar" aria-label="Onboarding completion" aria-valuemin="0" aria-valuemax="100" aria-valuenow="\${Math.round(progress.percent)}">
<div class="progress-fill" style="transform:scaleX(\${progress.percent / 100})"></div>
</div>
<div class="onboarding-steps">
\${progress.steps.map((step, index) => {
const position = index + 1;
const done = progress.active || position < progress.current;
const isCurrent = !progress.active && position === progress.current;
const className = done ? "done" : isCurrent ? "current" : "upcoming";
const marker = done ? "✓" : String(position);
return \`
<div class="onboarding-step \${className}" \${isCurrent ? 'aria-current="step"' : ""}>
<div class="step-heading">
<span class="step-marker" aria-hidden="true">\${marker}</span>
<strong>\${esc(step.title)}</strong>
</div>
<p class="step-copy">\${esc(step.description)}</p>
</div>\`;
}).join("")}
</div>
</nav>\`;
}
function evidenceItemsHtml(signals) {
return signals.map(signal => \`
<li class="evidence-item">
<span class="evidence-dot" aria-hidden="true"></span>
<p>\${esc(signal)}</p>
</li>\`).join("");
}
function evidenceHtml() {
const signals = Array.isArray(state.signals) ? state.signals : [];
if (!signals.length) return "";
const visible = signals.slice(0, 3);
const remaining = signals.slice(3);
return \`
<section class="evidence" aria-labelledby="evidence-title">
<div class="evidence-header">
<div>
<h2 id="evidence-title">Why Squad proposed this team</h2>
<p>These repository findings shaped the recommended roles and ownership boundaries. They are evidence—not setup tasks or warnings.</p>
</div>
<span class="evidence-count">\${signals.length} finding\${signals.length === 1 ? "" : "s"}</span>
</div>
<ul class="evidence-list">
\${evidenceItemsHtml(visible)}
</ul>
\${remaining.length ? \`
<details>
<summary>Show \${remaining.length} more finding\${remaining.length === 1 ? "" : "s"}</summary>
<ul class="evidence-list">
\${evidenceItemsHtml(remaining)}
</ul>
</details>\` : ""}
<div class="evidence-prompt">
<strong>Want to reshape the cast?</strong>
<span>Tell Copilot what to split, combine, add, or remove. The proposal updates before any pull request is created.</span>
</div>
</section>\`;
}
function setupHtml() {
const hasProposal = state.members.length > 0;
const automationStarted =
(state.operation?.kind === "automation-pr" && state.operation?.status === "running") ||
state.pullRequest?.kind === "automation-pr" ||
Boolean(state.onboarding?.automation);
return \`
\${onboardingProgressHtml()}
<section>
<h1>\${hasProposal ? "Meet the Squad we’d start with." : "Build the team this repo needs."}</h1>
<p class="lede">\${hasProposal
? esc(state.summary || "A repository-specific team proposal. Review the people and their charters; Squad owns the initial division of work.")
: "Copilot will inspect the repository and propose a small team with clear charters. You won’t be asked to predict hypothetical assignments during onboarding."}</p>
\${evidenceHtml()}
\${operationHtml()}
\${prHtml()}
\${castActionHtml()}
\${!hasProposal ? \`
<div class="hero-actions">
<button class="button primary" data-action="analyze" type="button" \${state.operation?.status === "running" ? "disabled" : ""}>Analyze repository</button>
</div>\` : \`
<div class="layout">
<section>
<div class="section-heading"><div><h2>Proposed team</h2><p>Squad recommends; you can refine.</p></div></div>
\${memberListHtml()}
<div class="summary">
<strong>\${state.members.length} members</strong> · charters stay editable · no assignments required yet
</div>
\${!automationStarted ? \`
<div class="actions" style="margin-top:18px">
<button class="button" data-action="reanalyze" type="button">Analyze again</button>
<button class="button primary" data-action="confirm-setup" type="button">Create automation PR · 1 of 2</button>
</div>\` : ""}
\${confirmSetup ? \`
<div class="confirmation">
<p><strong>Create onboarding pull request 1 of 2?</strong><br>This first PR installs gh-aw and the Squad workflows. After you merge it, ask Copilot to create the cast issue; Squad will open a separate PR containing the approved team. Neither PR is merged automatically.</p>
<div class="actions">
<button class="button" data-action="cancel-confirm" type="button">Cancel</button>
<button class="button primary" data-action="create-automation-pr" type="button">Create automation PR</button>
</div>
</div>\` : ""}
</section>
\${memberEditorHtml()}
</div>\`}
</section>\`;
}
function topologyHtml() {
const ownerIds = [...new Set(state.mission.tasks.map(task => task.ownerId))];
const owners = ownerIds.map(id => state.members.find(member => member.id === id)).filter(Boolean);
const lead = state.members.find(member => member.lead) || state.members[0];
const reviewer = state.members.find(member => member.reviewer);
return \`
<div class="topology" aria-label="Proposed mission topology">
<div class="topology-row"><div class="topology-node"><strong>\${esc(lead?.name || "Squad lead")}</strong><span>Plans and synthesizes</span></div></div>
<div class="connector" aria-hidden="true">↓</div>
<div class="topology-row">
\${owners.map(owner => '<div class="topology-node"><strong>' + esc(owner.name) + '</strong><span>' + esc(owner.role) + '</span></div>').join("")}
</div>
<div class="connector" aria-hidden="true">↓ evidence returns to lead ↓</div>
<div class="topology-row"><div class="topology-node"><strong>\${esc(reviewer?.name || "Independent review")}</strong><span>Reviews the exact candidate</span></div><div class="topology-node"><strong>Pull request</strong><span>Human review and merge</span></div></div>
</div>\`;
}
function missionHtml() {
if (!state.mission) {
return \`
<section class="mission-intro">
<h2>Give the Squad a real mission</h2>
<p>Describe the outcome. Squad will inspect the repo, break the work down, and propose ownership. You review exceptions instead of inventing assignments up front.</p>
<form data-form="mission">
<textarea class="goal" name="goal" placeholder="For example: Add OAuth device flow support and open a pull request."></textarea>
<div class="actions" style="margin-top:12px"><button class="button primary" type="submit">Plan with Squad</button></div>
</form>
</section>\`;
}
if (!state.mission.tasks?.length) {
return \`
<section class="mission-intro">
<h2>Planning the mission</h2>
<p>Squad is inspecting the repository, decomposing the goal, and choosing likely owners.</p>
<div class="operation"><span class="spinner" aria-hidden="true"></span><div><strong>Planning</strong><br>\${esc(state.mission.goal)}</div></div>
</section>\`;
}
return \`
<section class="mission-intro">
<div class="section-heading">
<div><h2>Review Squad’s plan</h2><p>\${esc(state.mission.summary || state.mission.goal)}</p></div>
<button class="button" data-action="clear-mission" type="button">Start over</button>
</div>
\${topologyHtml()}
<div class="task-list">
\${state.mission.tasks.map(task => \`
<article class="task">
<div>
<h3>\${esc(task.title)}</h3>
<p>\${esc(task.description || task.rationale)}</p>
\${task.overridden ? '<div class="override">Ownership changed by you</div>' : ""}
</div>
<label>
<span class="help">Owner</span>
<select data-action="task-owner" data-task-id="\${esc(task.id)}">
\${state.members.map(member => '<option value="' + esc(member.id) + '"' + (member.id === task.ownerId ? " selected" : "") + ">" + esc(member.name) + " · " + esc(member.role) + "</option>").join("")}
</select>
</label>
</article>
\`).join("")}
</div>
<div class="actions" style="margin-top:18px">
<button class="button primary" data-action="confirm-mission" type="button">Start work</button>
</div>
\${confirmMission ? \`
<div class="confirmation">
<p><strong>Execute this plan?</strong><br>The project session will coordinate specialist evidence, produce one reviewed candidate, and open a pull request. It will not merge.</p>
<div class="actions">
<button class="button" data-action="cancel-confirm" type="button">Cancel</button>
<button class="button primary" data-action="start-mission" type="button">Execute and create PR</button>
</div>
</div>\` : ""}
</section>\`;
}
function activeHtml() {
const dirtyCount = state.members.filter(member => member.dirty).length;
return \`
<section>
<h1>Your Squad is ready.</h1>
<p class="lede">The repository owns the team and charters. Give it a real mission; Squad proposes the work breakdown and likely owners before anything changes.</p>
\${operationHtml()}
\${prHtml()}
<div class="layout">
<section>
<div class="section-heading"><div><h2>Team</h2><p>\${state.members.length} authorized members</p></div></div>
\${memberListHtml()}
\${dirtyCount ? \`
<div class="dirty">\${dirtyCount} charter change\${dirtyCount === 1 ? "" : "s"} waiting to be applied.</div>
<div class="actions" style="margin-top:12px"><button class="button" data-action="confirm-charters" type="button">Create charter PR</button></div>
\${confirmCharters ? \`
<div class="confirmation">
<p>Create a focused pull request containing only these charter changes?</p>
<div class="actions">
<button class="button" data-action="cancel-confirm" type="button">Cancel</button>
<button class="button primary" data-action="create-charter-pr" type="button">Create pull request</button>
</div>
</div>\` : ""}
\` : ""}
</section>
\${memberEditorHtml()}
</div>
\${missionHtml()}
</section>\`;
}
function render() {
updateHeader();
if (!state) {
app.innerHTML = '<div class="operation"><span class="spinner"></span><div>Loading Squad…</div></div>';
return;
}
if (state.mode === "unavailable") {
app.innerHTML = \`
<section class="empty">
<h1>Open Squad from a project session.</h1>
<p>The canvas needs the app’s active repository worktree. Open or create a Copilot project session, then launch Squad again.</p>
</section>\`;
return;
}
if (!selectedMemberId && state.members.length) selectedMemberId = state.members[0].id;
app.innerHTML = state.mode === "setup" ? setupHtml() : activeHtml();
}
async function refresh() {
const response = await fetch("/api/state", { cache: "no-store" });
state = await response.json();
render();
}
document.addEventListener("click", async event => {
const target = event.target.closest("[data-action]");
if (!target) return;
const action = target.dataset.action;
try {
if (action === "select-member") {
selectedMemberId = target.dataset.id;
render();
} else if (action === "analyze" || action === "reanalyze") {
confirmSetup = false;
await post("/api/analyze");
} else if (action === "confirm-setup") {
confirmSetup = true;
render();
} else if (action === "confirm-cast") {
confirmCast = true;
render();
} else if (action === "confirm-mission") {
confirmMission = true;
render();
} else if (action === "confirm-charters") {
confirmCharters = true;
render();
} else if (action === "cancel-confirm") {
confirmSetup = confirmCast = confirmMission = confirmCharters = false;
render();
} else if (action === "create-automation-pr") {
confirmSetup = false;
await post("/api/create-automation-pr");
} else if (action === "create-cast-issue") {
confirmCast = false;
await post("/api/create-cast-issue");
} else if (action === "create-charter-pr") {
confirmCharters = false;
await post("/api/create-charter-pr");
} else if (action === "start-mission") {
confirmMission = false;
await post("/api/start-mission");
} else if (action === "clear-mission") {
await post("/api/clear-mission");
}
} catch (error) {
state.operation = { status: "error", message: error.message };
render();
}
});
document.addEventListener("change", async event => {
if (event.target.dataset.action !== "task-owner") return;
try {
await post("/api/task-owner", {
taskId: event.target.dataset.taskId,
ownerId: event.target.value
});
} catch (error) {
state.operation = { status: "error", message: error.message };
render();
}
});
document.addEventListener("submit", async event => {
const form = event.target;
event.preventDefault();
try {
if (form.dataset.form === "member") {
const data = new FormData(form);
await post("/api/member", {
id: form.dataset.id,
role: data.get("role"),
charter: data.get("charter")
});
} else if (form.dataset.form === "mission") {
const goal = String(new FormData(form).get("goal") || "").trim();
if (!goal) return;
await post("/api/plan-mission", { goal });
}
} catch (error) {
state.operation = { status: "error", message: error.message };
render();
}
});
const events = new EventSource("/events");
events.addEventListener("state", event => {
state = JSON.parse(event.data);
render();
});
events.onerror = () => {
if (state) {
state.operation = { status: "error", message: "Canvas connection interrupted. Reopen Squad to reconnect." };
render();
}
};
refresh();
setInterval(() => {
refresh().catch(error => console.error("Unable to refresh Squad onboarding state.", error));
}, 15000);
</script>
</body>
</html>`;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment