import { execFile as execFileCb } from "node:child_process";
import { randomUUID } from "node:crypto";
import path from "node:path";
import { promisify } from "node:util";
import { GoalManager } from "./goal-manager.js";
import { GoalStore } from "./goal-store.js";
import { createWorktree, cleanupWorktree } from "../skills/git.js";
import { TeamStore } from "./team-store.js";
import { bobbitStateDir } from "../bobbit-dir.js";
import { generateTeamName } from "./team-names.js";
import { checkGateDependencies } from "./gate-dependency-check.js";
const execFile = promisify(execFileCb);
/**
 * Build a markdown list of available roles (excluding team-lead and assistant)
 * for injection into the team lead prompt via {{AVAILABLE_ROLES}}.
 * Accepts anything with a getAll() method (RoleStore or RoleManager).
 */
export function buildAvailableRolesList(roleSource) {
    if (!roleSource)
        return "coder, reviewer, test-engineer";
    const allRoles = roleSource.getAll?.() ?? roleSource.listRoles?.() ?? [];
    const roles = allRoles.filter(r => r.name !== "team-lead" && r.name !== "assistant");
    if (roles.length === 0)
        return "No spawnable roles defined.";
    return roles.map(r => {
        const tools = r.toolPolicies ? Object.keys(r.toolPolicies).filter(k => r.toolPolicies[k] === 'allow').slice(0, 8).join(', ') || 'default' : 'default';
        return `- **${r.name}** (${r.label}) — tools: ${tools}`;
    }).join("\n");
}
export class GateDependencyError extends Error {
    constructor(message) {
        super(message);
        this.name = "GateDependencyError";
    }
}
/**
 * Format elapsed time since a timestamp as a human-readable string.
 * Exported for testing.
 */
export function formatElapsed(sinceMs) {
    const mins = Math.floor((Date.now() - sinceMs) / 60_000);
    if (mins < 60)
        return `${mins}m`;
    const h = Math.floor(mins / 60);
    const m = mins % 60;
    return `${h}h ${m}m`;
}
// Team lead extension path is resolved lazily via ToolManager.getExtensionPath().
import { TaskManager } from "./task-manager.js";
export class TeamManager {
    sessionManager;
    config;
    taskManager;
    teams = new Map();
    /** Local team store — used only in the non-PCM (test) path. */
    localStore;
    /** Local GoalManager — used only in the non-PCM (test) path. */
    _localGoalManager = null;
    /** Timers for the idle-nudge mechanism (goalId → timer). One-shot setTimeouts that reschedule with exponential backoff. */
    idleNudgeTimers = new Map();
    /** Count of consecutive workers-nudges sent for a goal (goalId → count). Reset on agent_start. */
    idleNudgeCount = new Map();
    /** Separate timer for nudging when no workers remain (goalId → timer). One-shot setTimeouts that reschedule with exponential backoff. */
    noWorkersNudgeTimers = new Map();
    /** Count of consecutive no-workers nudges sent for a goal (goalId → count).
     *  Reset only by external prompts (source "user" or "system"). */
    noWorkersNudgeCount = new Map();
    /** Guard flag: true while an auto-nudge prompt is pending (not yet processed by the agent). */
    nudgePending = new Map();
    verificationHarness;
    /** Base delay before nudging the idle team lead when workers are active (ms). Successive nudges back off exponentially up to MAX_IDLE_NUDGE_DELAY_MS. */
    static IDLE_NUDGE_DELAY_MS = 600_000;
    /** Maximum delay between workers-nudges (ms). Caps the exponential backoff. */
    static MAX_IDLE_NUDGE_DELAY_MS = 12 * 60 * 60 * 1000; // 12h
    /** Delay before nudging the idle team lead when no workers remain (ms). */
    static NO_WORKERS_NUDGE_DELAY_MS = 300_000;
    /** Maximum delay between no-workers nudges (ms). Caps the exponential backoff. */
    static MAX_NO_WORKERS_NUDGE_DELAY_MS = 12 * 60 * 60 * 1000; // 12h
    /**
     * If any team member is actively streaming, suppress the workers-nudge unless at
     * least one has been streaming longer than this threshold. Prevents nagging the
     * team lead while workers are making real progress.
     */
    static LONG_STREAMING_THRESHOLD_MS = 30 * 60 * 1000; // 30m
    /** Reverse lookup: sessionId → goalId for quick dismissal. */
    sessionToGoal = new Map();
    /** Track last notification time per worker session to debounce rapid agent_end events. */
    lastNotifyTime = new Map();
    /** In-flight startTeam promises to prevent concurrent team creation for the same goal. */
    startTeamLocks = new Map();
    constructor(sessionManager, config, stateDir) {
        this.sessionManager = sessionManager;
        this.config = config;
        this.taskManager = config.taskManager;
        if (config.projectContextManager) {
            this.localStore = null;
        }
        else {
            const dir = stateDir ?? bobbitStateDir();
            this.localStore = new TeamStore(dir);
            // Non-PCM test path: create a local GoalManager from the same stateDir
            this._localGoalManager = new GoalManager(new GoalStore(dir));
        }
        this.restoreTeams();
    }
    resolveTeamStore(goalId) {
        if (this.config.projectContextManager) {
            const ctx = this.config.projectContextManager.getContextForGoal(goalId);
            if (ctx)
                return ctx.teamStore;
            throw new Error(`Cannot resolve team store: goal "${goalId}" not found in any project`);
        }
        return this.localStore;
    }
    resolveGateStore(goalId) {
        if (this.config.projectContextManager) {
            const ctx = this.config.projectContextManager.getContextForGoal(goalId);
            if (ctx)
                return ctx.gateStore;
            throw new Error(`Cannot resolve gate store: goal "${goalId}" not found in any project`);
        }
        // No PCM configured (test path) — no gate store available
        return undefined;
    }
    /** Set the broadcastToGoal function (called after WebSocket server is created). */
    setBroadcastToGoal(fn) {
        this.config.broadcastToGoal = fn;
    }
    /** Wire in the verification harness so nudge logic can check for active verifications. */
    setVerificationHarness(harness) {
        this.verificationHarness = harness;
    }
    /** Pick a palette index (0-19) not already used by any session, with randomisation. */
    assignUniqueColor(sessionId) {
        const PALETTE_SIZE = 20;
        const used = new Set();
        for (const [, idx] of Object.entries(this.config.colorStore.getAll())) {
            used.add(idx);
        }
        // Collect available indices and pick one at random
        const available = [];
        for (let i = 0; i < PALETTE_SIZE; i++) {
            if (!used.has(i))
                available.push(i);
        }
        const idx = available.length > 0
            ? available[Math.floor(Math.random() * available.length)]
            : Math.floor(Math.random() * PALETTE_SIZE);
        this.config.colorStore.set(sessionId, idx);
    }
    /**
     * Convert an in-memory TeamEntry to a PersistedTeamEntry for storage.
     */
    toPersistedEntry(entry) {
        return {
            goalId: entry.goalId,
            teamLeadSessionId: entry.teamLeadSessionId,
            agents: entry.agents.map((a) => ({
                sessionId: a.sessionId,
                role: a.role,
                kind: a.kind,
                worktreePath: a.worktreePath,
                branch: a.branch,
                baseSha: a.baseSha,
                task: a.task,
                createdAt: a.createdAt,
            })),
            maxConcurrent: entry.maxConcurrent,
        };
    }
    /**
     * Persist the current state of a team entry to disk.
     */
    persistEntry(goalId) {
        const entry = this.teams.get(goalId);
        if (entry) {
            this.resolveTeamStore(goalId).put(this.toPersistedEntry(entry));
        }
    }
    /**
     * Restore teams from disk persistence.
     * Reconstructs the in-memory Maps from the persisted store.
     */
    /**
     * Phase 1: Restore team data structures and reverse lookups from disk.
     * Called from the constructor (before sessions are restored).
     * Event subscriptions are deferred to resubscribeTeamEvents().
     */
    restoreTeams() {
        let persisted;
        if (this.config.projectContextManager) {
            persisted = [];
            for (const ctx of this.config.projectContextManager.all()) {
                persisted.push(...ctx.teamStore.getAll());
            }
        }
        else {
            persisted = this.localStore.getAll();
        }
        for (const p of persisted) {
            const entry = {
                goalId: p.goalId,
                teamLeadSessionId: p.teamLeadSessionId,
                agents: p.agents.map((a) => ({
                    sessionId: a.sessionId,
                    role: a.role,
                    // Default to "worker" for back-compat with persisted entries
                    // written before the kind field was introduced.
                    kind: (a.kind === "reviewer" ? "reviewer" : "worker"),
                    worktreePath: a.worktreePath,
                    branch: a.branch,
                    baseSha: a.baseSha,
                    task: a.task,
                    createdAt: a.createdAt,
                })),
                maxConcurrent: p.maxConcurrent,
            };
            this.teams.set(p.goalId, entry);
            // Rebuild reverse lookup
            if (p.teamLeadSessionId) {
                this.sessionToGoal.set(p.teamLeadSessionId, p.goalId);
            }
            for (const agent of entry.agents) {
                this.sessionToGoal.set(agent.sessionId, p.goalId);
            }
            console.log(`[team-manager] Restored team for goal ${p.goalId} — team lead: ${p.teamLeadSessionId}, agents: ${entry.agents.length}`);
        }
    }
    /**
     * Phase 2: Re-subscribe to team lead and worker agent events.
     * Must be called AFTER sessions have been restored (restoreSessions()),
     * because it needs live session objects to attach event listeners.
     */
    resubscribeTeamEvents() {
        for (const [goalId, entry] of this.teams) {
            // Re-subscribe to team lead events and restart idle timer if needed
            if (entry.teamLeadSessionId) {
                const tlSession = this.sessionManager.getSession(entry.teamLeadSessionId);
                if (tlSession && tlSession.status !== "terminated") {
                    this.subscribeTeamLeadEvents(goalId);
                    if (tlSession.status === "idle") {
                        this.startIdleNudgeTimer(goalId);
                    }
                }
            }
            // Re-subscribe to worker agent events so the team lead is notified
            // when workers go idle (these subscriptions are lost on restart).
            // Reviewer sessions are managed by VerificationHarness — never attach
            // the agent_end → notifyTeamLead listener for them.
            for (const agent of entry.agents) {
                if (agent.kind === "reviewer" || agent.role === "reviewer")
                    continue;
                const workerSession = this.sessionManager.getSession(agent.sessionId);
                if (!workerSession || workerSession.status === "terminated")
                    continue;
                const { role, sessionId } = agent;
                const agentId = `${role}-${sessionId.slice(0, 8)}`;
                const unsubscribe = workerSession.rpcClient.onEvent((event) => {
                    if (event.type !== "agent_end")
                        return;
                    this.notifyTeamLead(goalId, sessionId, role, agentId).catch((err) => {
                        console.error("[team-manager] Failed to notify team lead:", err);
                    });
                });
                agent.unsubscribeEvent = unsubscribe;
            }
        }
        console.log(`[team-manager] Re-subscribed to events for ${this.teams.size} team(s)`);
    }
    /**
     * Clear and remove all idle-nudge timers for a goal.
     */
    clearIdleNudgeTimer(goalId) {
        const timer = this.idleNudgeTimers.get(goalId);
        if (timer) {
            clearTimeout(timer);
            this.idleNudgeTimers.delete(goalId);
        }
        this.idleNudgeCount.delete(goalId);
        const nwTimer = this.noWorkersNudgeTimers.get(goalId);
        if (nwTimer) {
            clearTimeout(nwTimer);
            this.noWorkersNudgeTimers.delete(goalId);
        }
        this.noWorkersNudgeCount.delete(goalId);
        this.nudgePending.delete(goalId);
    }
    /**
     * Format elapsed time since a timestamp.
     */
    formatElapsed(sinceMs) {
        return formatElapsed(sinceMs);
    }
    /** Common pre-checks for nudge timer ticks. Returns true if the nudge should be skipped. */
    shouldSkipNudge(goalId) {
        const entry = this.teams.get(goalId);
        if (!entry?.teamLeadSessionId)
            return true;
        const tl = this.sessionManager.getSession(entry.teamLeadSessionId);
        if (!tl || tl.status !== "idle")
            return true;
        if (this.verificationHarness?.getActiveVerifications(goalId).length)
            return true;
        if (this.nudgePending.get(goalId))
            return true;
        // Don't nudge a team lead whose goal has already finished (complete/shelved/archived).
        const goal = this.resolveGoal(goalId);
        if (!goal || goal.archived || goal.state === "complete" || goal.state === "shelved")
            return true;
        return false;
    }
    /** Get active (non-reviewer, non-terminated) workers for a goal. */
    getActiveWorkers(goalId) {
        const entry = this.teams.get(goalId);
        if (!entry)
            return [];
        return entry.agents.filter((a) => {
            if (a.role === 'reviewer')
                return false;
            const s = this.sessionManager.getSession(a.sessionId);
            return s && s.status !== "terminated";
        });
    }
    /**
     * Start both idle-nudge timers when the team lead goes idle.
     *
     * Two independent repeating timers run in parallel:
     * - **No-workers timer** (5 min): fires when the team lead is idle with zero
     *   active workers — one-shot nudge then self-clears.
     * - **Workers timer** (10 min): fires when the team lead is idle while workers
     *   are still active — repeats every 10 min.
     *
     * Each timer checks conditions on every tick and returns early if they don't apply.
     */
    startIdleNudgeTimer(goalId) {
        // Clear any pending timers but PRESERVE counters — callers either come
        // from teardown (clearIdleNudgeTimer already ran) or from agent_end
        // (where we want continued backoff). Reset is the job of
        // clearIdleNudgeTimer / the external-prompt branch of subscribeTeamLeadEvents.
        const existingWorkers = this.idleNudgeTimers.get(goalId);
        if (existingWorkers) {
            clearTimeout(existingWorkers);
            this.idleNudgeTimers.delete(goalId);
        }
        const existingNoWorkers = this.noWorkersNudgeTimers.get(goalId);
        if (existingNoWorkers) {
            clearTimeout(existingNoWorkers);
            this.noWorkersNudgeTimers.delete(goalId);
        }
        this.nudgePending.delete(goalId);
        // --- No-workers timer (one-shot, reschedules with exponential backoff) ---
        // Each successive nudge (without the lead acting on external input) doubles
        // the delay: 5m, 10m, 20m, 40m, … capped at MAX_NO_WORKERS_NUDGE_DELAY_MS (12h).
        // The counter resets only on external (user/system) prompt via
        // clearIdleNudgeTimer() in subscribeTeamLeadEvents.
        this.scheduleNoWorkersNudge(goalId);
        // --- Workers timer (one-shot, reschedules with exponential backoff) ---
        // Each successive nudge (without the lead acting) doubles the delay:
        // 10m, 20m, 40m, 80m, … capped at MAX_IDLE_NUDGE_DELAY_MS (12h).
        // The counter resets on external prompt via clearIdleNudgeTimer().
        this.scheduleWorkersNudge(goalId);
    }
    /**
     * Schedule the next no-workers nudge for a goal using exponential backoff.
     * Delay = NO_WORKERS_NUDGE_DELAY_MS * 2^count, capped at MAX_NO_WORKERS_NUDGE_DELAY_MS.
     * Aborts cleanly without incrementing the counter if a worker appears before
     * the timer fires — the workers-nudge takes over that case.
     */
    scheduleNoWorkersNudge(goalId) {
        const count = this.noWorkersNudgeCount.get(goalId) ?? 0;
        const delay = Math.min(TeamManager.NO_WORKERS_NUDGE_DELAY_MS * Math.pow(2, count), TeamManager.MAX_NO_WORKERS_NUDGE_DELAY_MS);
        const timer = setTimeout(() => {
            this.noWorkersNudgeTimers.delete(goalId);
            if (this.shouldSkipNudge(goalId))
                return;
            if (this.getActiveWorkers(goalId).length > 0) {
                // Workers appeared — workers-nudge owns this case. Don't increment, don't reschedule.
                return;
            }
            const entry = this.teams.get(goalId);
            const goal = this.resolveGoal(goalId);
            const goalTitle = goal?.title || goalId.slice(0, 8);
            const message = `[AUTO-NUDGE] You have been idle for a while and have no active team agents. ` +
                `Goal: "${goalTitle}". ` +
                `Check your progress — use task_list and gate_list to review what's done and what remains. ` +
                `If there's more work to do, spawn agents or do it yourself. ` +
                `If all work is complete and gates are passed, call team_complete to finish the goal.`;
            this.nudgePending.set(goalId, true);
            this.sessionManager.enqueuePrompt(entry.teamLeadSessionId, message, { isSteered: true, source: "auto-nudge" });
            this.noWorkersNudgeCount.set(goalId, count + 1);
            const nextDelay = Math.min(TeamManager.NO_WORKERS_NUDGE_DELAY_MS * Math.pow(2, count + 1), TeamManager.MAX_NO_WORKERS_NUDGE_DELAY_MS);
            console.log(`[team-manager] Sent no-workers nudge #${count + 1} to team lead for goal ${goalId}; ` +
                `next nudge in ${Math.round(nextDelay / 60000)}m`);
            this.scheduleNoWorkersNudge(goalId);
        }, delay);
        this.noWorkersNudgeTimers.set(goalId, timer);
    }
    /**
     * Schedule the next workers-nudge for a goal using exponential backoff.
     * Delay = IDLE_NUDGE_DELAY_MS * 2^count, capped at MAX_IDLE_NUDGE_DELAY_MS.
     */
    scheduleWorkersNudge(goalId) {
        const count = this.idleNudgeCount.get(goalId) ?? 0;
        const delay = Math.min(TeamManager.IDLE_NUDGE_DELAY_MS * Math.pow(2, count), TeamManager.MAX_IDLE_NUDGE_DELAY_MS);
        const timer = setTimeout(() => {
            this.idleNudgeTimers.delete(goalId);
            if (this.shouldSkipNudge(goalId))
                return;
            const activeWorkers = this.getActiveWorkers(goalId);
            if (activeWorkers.length === 0) {
                // No workers — handled by the other timer. Don't increment backoff.
                this.scheduleWorkersNudge(goalId);
                return;
            }
            // If any workers are actively streaming, only nudge when at least one has
            // been streaming for longer than LONG_STREAMING_THRESHOLD_MS. Workers that
            // are streaming quickly are making progress — don't interrupt the lead
            // to nag about them.
            const streamingWorkers = activeWorkers
                .map((a) => this.sessionManager.getSession(a.sessionId))
                .filter((s) => !!s && s.status === "streaming");
            if (streamingWorkers.length > 0) {
                const now = Date.now();
                const anyLongRunning = streamingWorkers.some((s) => {
                    const since = s.streamingStartedAt;
                    return typeof since === "number" && now - since > TeamManager.LONG_STREAMING_THRESHOLD_MS;
                });
                if (!anyLongRunning) {
                    console.log(`[team-manager] Skipping workers-nudge for goal ${goalId} — ` +
                        `${streamingWorkers.length} worker(s) streaming, none beyond threshold`);
                    // Don't increment backoff — the workers are making progress.
                    this.scheduleWorkersNudge(goalId);
                    return;
                }
            }
            const entry = this.teams.get(goalId);
            const lines = activeWorkers.map((agent) => {
                const s = this.sessionManager.getSession(agent.sessionId);
                const status = s?.status ?? "unknown";
                const tasks = this.resolveTasksForSession(goalId, agent.sessionId);
                const taskInfo = tasks.length > 0
                    ? `task "${tasks[0].title}" (${tasks[0].state})`
                    : "no assigned task";
                const elapsed = this.formatElapsed(agent.createdAt);
                const shortId = agent.sessionId.slice(0, 4);
                return `- Agent ${agent.role}-${shortId} (${agent.role}): ${status}, ${taskInfo}, running ${elapsed}`;
            });
            const message = `[AUTO-NUDGE] Team check-in — your agents' current status:\n${lines.join("\n")}\n\n` +
                `Review their progress. If an agent appears stuck or going in the wrong direction, steer them back on track. ` +
                `If an agent is idle and their work looks complete, mark their task as done and dismiss them. ` +
                `If idle agents have more to do, prompt them to continue.`;
            this.nudgePending.set(goalId, true);
            this.sessionManager.enqueuePrompt(entry.teamLeadSessionId, message, { isSteered: true, source: "auto-nudge" });
            this.idleNudgeCount.set(goalId, count + 1);
            const nextDelay = Math.min(TeamManager.IDLE_NUDGE_DELAY_MS * Math.pow(2, count + 1), TeamManager.MAX_IDLE_NUDGE_DELAY_MS);
            console.log(`[team-manager] Sent idle nudge #${count + 1} to team lead for goal ${goalId}; ` +
                `next nudge in ${Math.round(nextDelay / 60000)}m`);
            // Reschedule with the incremented counter
            this.scheduleWorkersNudge(goalId);
        }, delay);
        this.idleNudgeTimers.set(goalId, timer);
    }
    /**
     * Subscribe to the team lead session's RPC events to manage the idle-nudge timer.
     * On agent_end (idle): start the timer. On agent_start (streaming): clear it.
     */
    subscribeTeamLeadEvents(goalId) {
        const entry = this.teams.get(goalId);
        if (!entry?.teamLeadSessionId)
            return;
        const session = this.sessionManager.getSession(entry.teamLeadSessionId);
        if (!session)
            return;
        // Clean up any previous subscription
        entry.unsubscribeTeamLeadEvents?.();
        const unsubscribe = session.rpcClient.onEvent((event) => {
            if (event.type === "agent_end") {
                this.startIdleNudgeTimer(goalId);
            }
            else if (event.type === "agent_start") {
                this.nudgePending.delete(goalId);
                const tl = this.sessionManager.getSession(entry.teamLeadSessionId);
                const lastSource = tl?.lastPromptSource ?? "user";
                if (lastSource === "user" || lastSource === "system") {
                    // External signal — fresh idle cycle starts from base delay.
                    this.clearIdleNudgeTimer(goalId);
                }
                else {
                    // Team lead is replying to its own auto-nudge / task-notification / verification.
                    // Cancel pending timers (shouldSkipNudge would block them while streaming anyway),
                    // but PRESERVE counters so backoff continues to grow across cycles.
                    const t1 = this.idleNudgeTimers.get(goalId);
                    if (t1) {
                        clearTimeout(t1);
                        this.idleNudgeTimers.delete(goalId);
                    }
                    const t2 = this.noWorkersNudgeTimers.get(goalId);
                    if (t2) {
                        clearTimeout(t2);
                        this.noWorkersNudgeTimers.delete(goalId);
                    }
                    // idleNudgeCount and noWorkersNudgeCount intentionally preserved.
                }
            }
        });
        entry.unsubscribeTeamLeadEvents = unsubscribe;
    }
    get goalManager() {
        // PCM-active paths should use resolveGoalManager() instead.
        // This getter is only for the non-PCM test path.
        if (this.config.projectContextManager) {
            throw new Error("goalManager getter must not be called when PCM is active; use resolveGoalManager(goalId) instead");
        }
        // Support mock SessionManagers that expose goalManager directly (test path)
        if (this.sessionManager.goalManager)
            return this.sessionManager.goalManager;
        if (this._localGoalManager)
            return this._localGoalManager;
        throw new Error("goalManager getter requires PCM or local GoalManager");
    }
    /**
     * Resolve tasks for a session using the correct project's TaskManager.
     * When PCM is active, resolves via the goal's project context.
     */
    resolveTasksForSession(goalId, sessionId) {
        if (this.config.projectContextManager) {
            const ctx = this.config.projectContextManager.getContextForGoal(goalId);
            if (ctx)
                return new TaskManager(ctx.taskStore).getTasksForSession(sessionId);
            return []; // Goal not found in any project — no tasks to return
        }
        return this.taskManager.getTasksForSession(sessionId);
    }
    /** Resolve a goal across all project contexts. */
    resolveGoal(goalId) {
        if (this.config.projectContextManager) {
            const ctx = this.config.projectContextManager.getContextForGoal(goalId);
            if (ctx)
                return ctx.goalStore.get(goalId);
            return undefined; // Don't fall back to default project
        }
        return this.goalManager.getGoal(goalId); // non-PCM test path only
    }
    /** Get a GoalManager scoped to the project owning the given goal. */
    resolveGoalManager(goalId) {
        if (this.config.projectContextManager) {
            const ctx = this.config.projectContextManager.getContextForGoal(goalId);
            if (ctx)
                return ctx.goalManager;
            throw new Error(`Cannot resolve GoalManager: goal "${goalId}" not found in any project`);
        }
        return this.goalManager; // non-PCM test path only
    }
    /**
     * Start a team for the given goal.
     * Creates a Team Lead session and returns it.
     */
    async startTeam(goalId) {
        // Prevent concurrent startTeam calls for the same goal (race condition guard).
        // If another call is already in flight, return its result instead of creating a second team lead.
        const inflight = this.startTeamLocks.get(goalId);
        if (inflight) {
            return inflight;
        }
        const promise = this._startTeamImpl(goalId);
        this.startTeamLocks.set(goalId, promise);
        try {
            return await promise;
        }
        finally {
            this.startTeamLocks.delete(goalId);
        }
    }
    async _startTeamImpl(goalId) {
        const goal = this.resolveGoal(goalId);
        if (!goal) {
            throw new Error(`Goal not found: ${goalId}`);
        }
        if (!goal.team) {
            throw new Error(`Goal "${goal.title}" does not have team mode enabled`);
        }
        if (this.teams.has(goalId)) {
            throw new Error(`Team already active for goal: ${goalId}`);
        }
        // Use the goal's worktree/cwd for the team lead
        const cwd = goal.worktreePath || goal.cwd;
        // Build the Team Lead role prompt with structural placeholders only
        // Secrets (gateway URL, auth token, goal ID) are passed as env vars, NOT embedded in prompt text
        const roleStore = this.config.roleStore;
        const storedRole = roleStore?.get("team-lead");
        if (!storedRole) {
            throw new Error('Role "team-lead" not found. Ensure roles/team-lead.yaml exists.');
        }
        const teamLeadPromptTemplate = storedRole.promptTemplate;
        const teamLeadPrompt = teamLeadPromptTemplate
            .replace(/\{\{GOAL_BRANCH\}\}/g, goal.branch || "main")
            .replace(/\{\{AGENT_ID\}\}/g, `team-lead-${goalId.slice(0, 8)}`)
            .replace(/\{\{AVAILABLE_ROLES\}\}/g, buildAvailableRolesList(roleStore));
        // Create the team lead session with the team tools extension.
        // The extension registers first-class tools (team_spawn, task_create, etc.) in the agent.
        // When sandboxed, create a worktree inside the per-project container for the goal branch.
        const sandboxed = goal.sandboxed ?? this.sessionManager.isSandboxEnabled;
        // Resolve team-lead extension via cascade (ToolManager) or fall back to deprecated TOOLS_DIR
        let teamLeadExtPath;
        if (this.config.toolManager) {
            teamLeadExtPath = this.config.toolManager.getExtensionPath("team", "extension.ts");
        }
        else {
            const { TOOLS_DIR } = await import("./tool-manager.js");
            teamLeadExtPath = path.join(TOOLS_DIR, "team", "extension.ts");
        }
        const session = await this.sessionManager.createSession(cwd, ["--extension", teamLeadExtPath], goalId, undefined, {
            rolePrompt: teamLeadPrompt,
            roleName: "team-lead",
            env: { BOBBIT_GOAL_ID: goalId },
            sandboxed,
            // For sandboxed goals, create a worktree at the goal branch inside the container
            sandboxBranch: sandboxed && goal.branch ? goal.branch : undefined,
        });
        // Assign a unique color and title
        this.assignUniqueColor(session.id);
        const teamLeadName = await generateTeamName("team-lead");
        this.sessionManager.setTitle(session.id, `Team Lead: ${teamLeadName}`);
        session.titleGenerated = true;
        const teamLeadAccessory = storedRole?.accessory ?? "crown";
        this.sessionManager.updateSessionMeta(session.id, {
            role: "team-lead",
            teamGoalId: goalId,
            worktreePath: goal.worktreePath,
            accessory: teamLeadAccessory,
        });
        // Initialize team tracking
        const entry = {
            goalId,
            teamLeadSessionId: session.id,
            agents: [],
            maxConcurrent: 12,
        };
        this.teams.set(goalId, entry);
        this.sessionToGoal.set(session.id, goalId);
        this.persistEntry(goalId);
        // Subscribe to team lead lifecycle events for idle-nudge timer
        this.subscribeTeamLeadEvents(goalId);
        // Transition goal to in-progress if needed
        if (goal.state === "todo") {
            await this.resolveGoalManager(goalId).updateGoal(goalId, { state: "in-progress" });
        }
        // Kick off the team lead with an initial prompt (same pattern as delegate sessions)
        session.rpcClient.prompt("Execute the task described in your system prompt. Follow the instructions carefully.").catch((err) => {
            console.error("[team-manager] Failed to send team lead kickoff prompt:", err);
        });
        console.log(`[team-manager] Started team for goal "${goal.title}" — team lead: ${session.id}`);
        return session;
    }
    /**
     * Spawn a role agent for a team goal.
     * Creates an isolated git worktree and a session with the role's system prompt.
     * Sends the task as the first prompt.
     */
    /**
     * Build context from passed upstream dependency gates.
     *
     * If `explicitInputIds` is provided, those workflow gate IDs are used directly.
     * Otherwise, auto-resolves from the DAG's `dependsOn` for `workflowGateId`.
     */
    buildDependencyContext(goalId, workflowGateId, explicitInputIds) {
        const goal = this.resolveGoal(goalId);
        if (!goal?.workflow)
            return "";
        const resolvedGateStore = this.resolveGateStore(goalId);
        if (!resolvedGateStore)
            return "";
        // Determine which gate IDs to inject content from
        let inputIds;
        if (explicitInputIds && explicitInputIds.length > 0) {
            inputIds = explicitInputIds;
        }
        else if (workflowGateId) {
            const wfGate = goal.workflow.gates.find(g => g.id === workflowGateId);
            if (!wfGate || !wfGate.dependsOn?.length)
                return "";
            inputIds = wfGate.dependsOn;
        }
        else {
            return "";
        }
        const gateStates = resolvedGateStore.getGatesForGoal(goalId);
        const parts = [];
        for (const depId of inputIds) {
            const gateDef = goal.workflow.gates.find(g => g.id === depId);
            const gateState = gateStates.find(g => g.gateId === depId);
            if (gateDef && gateState && gateState.status === "passed" && gateDef.injectDownstream && gateState.currentContent) {
                parts.push(`## Gate: ${gateDef.name} (passed)\n\n${gateState.currentContent}`);
            }
        }
        if (parts.length === 0 && inputIds.length > 0) {
            const injectableCount = inputIds.filter(depId => {
                const gateDef = goal.workflow.gates.find(g => g.id === depId);
                return gateDef?.injectDownstream;
            }).length;
            if (injectableCount > 0) {
                console.warn(`[team-manager] buildDependencyContext: workflowGateId="${workflowGateId}" has ${injectableCount} ` +
                    `injectable upstream gate(s) but none produced content. Gates checked: ${inputIds.join(", ")}`);
            }
        }
        if (parts.length === 0)
            return "";
        return "\n\n# Upstream Gates\n\nContent from passed upstream gates:\n\n" + parts.join("\n\n---\n\n");
    }
    /**
     * Try to extract a workflowGateId from the task description.
     * Looks for a pattern like `[gate:some-id]` in the task text.
     */
    extractWorkflowGateId(task, goalId) {
        // Check for explicit tag
        const tagMatch = task.match(/\[gate:([^\]]+)\]/);
        if (tagMatch)
            return tagMatch[1];
        // Try to match against workflow gate names/IDs in the goal
        const goal = this.resolveGoal(goalId);
        if (!goal?.workflow)
            return undefined;
        const taskLower = task.toLowerCase();
        for (const gate of goal.workflow.gates) {
            if (taskLower.includes(gate.name.toLowerCase()) || task.includes(gate.id)) {
                return gate.id;
            }
        }
        return undefined;
    }
    async spawnRole(goalId, role, task, opts) {
        const roleStore = this.config.roleStore;
        const storedRoleDef = roleStore?.get(role);
        if (!storedRoleDef) {
            const available = roleStore?.getAll().map(r => r.name).join(", ") ?? "none";
            throw new Error(`Role "${role}" not found. Available roles: ${available}`);
        }
        if (role === 'team-lead') {
            throw new Error('Cannot spawn team-lead role via spawnRole — use startTeam() instead');
        }
        const entry = this.teams.get(goalId);
        if (!entry) {
            throw new Error(`No active team for goal: ${goalId}`);
        }
        // Check concurrency limit
        if (entry.agents.length >= entry.maxConcurrent) {
            throw new Error(`Team for goal ${goalId} already has ${entry.agents.length} agents (max: ${entry.maxConcurrent})`);
        }
        const goal = this.resolveGoal(goalId);
        if (!goal) {
            throw new Error(`Goal not found: ${goalId}`);
        }
        // repoPath is only set when the goal's cwd is inside a git repo.
        // If absent, skip worktree creation and use the goal's cwd directly.
        const useWorktree = !!goal.repoPath;
        // Enforce gate dependency check: upstream gates must be passed before spawning for a gate
        const resolvedWorkflowGateId = opts?.workflowGateId ?? this.extractWorkflowGateId(task, goalId);
        const spawnGateStore = this.resolveGateStore(goalId);
        if (resolvedWorkflowGateId && goal.workflow && spawnGateStore) {
            const gateStates = spawnGateStore.getGatesForGoal(goalId);
            const depError = checkGateDependencies(resolvedWorkflowGateId, goal.workflow.gates, gateStates);
            if (depError) {
                throw new GateDependencyError(depError);
            }
        }
        // Create a worktree for this role agent (only when the goal is in a git repo).
        // Branch shape pinned by tests/team-branch-shape.test.ts:
        //   goal/<goalId8>/<role>-<short4>
        // `createWorktree` flattens slashes to hyphens for the worktree dirname,
        // so the on-disk directory is `goal-<goalId8>-<role>-<short4>`.
        const shortId = randomUUID().slice(0, 4);
        let worktreeResult;
        let branchName;
        let agentCwd;
        const memberSandboxed = goal.sandboxed ?? this.sessionManager.isSandboxEnabled;
        if (useWorktree) {
            const goalId8 = goalId.slice(0, 8);
            branchName = `goal/${goalId8}/${role}-${shortId}`;
            // Fetch latest so origin/<goal-branch> is up to date for the worktree start-point
            try {
                await execFile("git", ["fetch", "origin", goal.branch], { cwd: goal.repoPath, timeout: 30_000 });
            }
            catch { /* fetch failure is non-fatal — worktree falls back to local HEAD */ }
            // Compute subdirectory offset from the goal's worktree root to its cwd.
            // If the project rootPath is a subdirectory of the repo, goal.cwd includes
            // the offset (e.g. worktreePath + "/packages/my-app"), and we must apply
            // the same offset to the member's worktree.
            const memberSubdirOffset = goal.worktreePath
                ? path.relative(goal.worktreePath, goal.cwd)
                : "";
            if (memberSandboxed && this.sessionManager.getSandboxManager()) {
                // Sandboxed: worktree created inside the container by applySandboxWiring
                // via ProjectSandbox.createWorktree(). Use goal.cwd as placeholder.
                agentCwd = goal.cwd; // placeholder — sandbox wiring overrides this
            }
            else {
                // Non-sandboxed: create worktree the traditional way
                worktreeResult = await createWorktree(goal.repoPath, branchName, { startPoint: goal.branch ? `origin/${goal.branch}` : undefined });
                // Apply subdirectory offset to member worktree cwd
                agentCwd = memberSubdirOffset && memberSubdirOffset !== "."
                    ? path.join(worktreeResult.worktreePath, memberSubdirOffset)
                    : worktreeResult.worktreePath;
            }
        }
        else {
            agentCwd = goal.cwd;
        }
        try {
            const agentId = `${role}-${shortId}`;
            const rolePromptTemplate = storedRoleDef.promptTemplate;
            const rolePrompt = rolePromptTemplate
                .replace(/\{\{GOAL_BRANCH\}\}/g, goal.branch || "main")
                .replace(/\{\{AGENT_ID\}\}/g, agentId);
            // Build workflow dependency context for the system prompt
            let workflowContext;
            const wfGateId = opts?.workflowGateId ?? this.extractWorkflowGateId(task, goalId);
            const explicitInputs = opts?.inputGateIds;
            if (explicitInputs?.length || wfGateId) {
                const ctx = this.buildDependencyContext(goalId, wfGateId, explicitInputs);
                if (ctx)
                    workflowContext = ctx;
            }
            // Create the session with the role agent's cwd.
            // For sandboxed members, create a worktree inside the per-project container.
            const session = await this.sessionManager.createSession(agentCwd, undefined, goalId, undefined, {
                rolePrompt, roleName: role, workflowContext, sandboxed: memberSandboxed,
                // Pass branch info so applySandboxWiring creates the worktree inside the container
                sandboxBranch: memberSandboxed && branchName ? branchName : undefined,
                sandboxBaseBranch: memberSandboxed && branchName && goal.branch ? `origin/${goal.branch}` : undefined,
            });
            // Assign a unique color and title
            this.assignUniqueColor(session.id);
            const roleName = await generateTeamName(role);
            const roleLabel = role.charAt(0).toUpperCase() + role.slice(1);
            this.sessionManager.setTitle(session.id, `${roleLabel}: ${roleName}`);
            session.titleGenerated = true;
            const roleAccessory = storedRoleDef.accessory;
            // For sandboxed sessions, the actual worktree is session.cwd (set by ProjectSandbox.createWorktree)
            const actualWorktreePath = worktreeResult?.worktreePath || (memberSandboxed ? session.cwd : undefined);
            this.sessionManager.updateSessionMeta(session.id, {
                role,
                teamGoalId: goalId,
                worktreePath: actualWorktreePath,
                accessory: roleAccessory,
                teamLeadSessionId: entry.teamLeadSessionId ?? undefined,
            });
            // Resolve baseSha from the agent's working directory.
            // For sandboxed sessions, run git inside the container.
            let baseSha;
            try {
                const effectiveCwd = actualWorktreePath || session.cwd || agentCwd;
                if (memberSandboxed && this.sessionManager.getSandboxManager()) {
                    const sandbox = this.sessionManager.getSandboxManager().get(goal.projectId || "");
                    if (sandbox) {
                        const output = await sandbox.exec(["git", "rev-parse", "HEAD"], { cwd: effectiveCwd });
                        baseSha = output.trim() || undefined;
                    }
                }
                else {
                    const { stdout } = await execFile("git", ["rev-parse", "HEAD"], { cwd: effectiveCwd, timeout: 5_000 });
                    baseSha = stdout.trim() || undefined;
                }
            }
            catch { /* non-fatal — baseSha stays undefined */ }
            // Track the agent
            const agent = {
                sessionId: session.id,
                role,
                kind: "worker",
                worktreePath: actualWorktreePath,
                branch: branchName,
                baseSha,
                task,
                createdAt: Date.now(),
            };
            entry.agents.push(agent);
            this.sessionToGoal.set(session.id, goalId);
            this.persistEntry(goalId);
            // Enrich task prompt with upstream dependency context if available
            const enrichedTask = workflowContext ? task + workflowContext : task;
            // Send the task as the first prompt
            session.rpcClient.prompt(enrichedTask).catch((err) => {
                console.error('[team-manager] Failed to send task prompt:', err);
            });
            // Subscribe to worker events to steer the team lead when the worker goes idle
            const unsubscribe = session.rpcClient.onEvent((event) => {
                if (event.type !== "agent_end")
                    return;
                // Broadcast team agent finished event
                this.config.broadcastToGoal?.(goalId, {
                    type: "team_agent_finished", goalId, sessionId: session.id, role, name: roleName,
                });
                this.notifyTeamLead(goalId, session.id, role, agentId).catch((err) => {
                    console.error("[team-manager] Failed to notify team lead:", err);
                });
            });
            agent.unsubscribeEvent = unsubscribe;
            console.log(`[team-manager] Spawned ${role} agent (${session.id}) for goal "${goal.title}" — cwd: ${agentCwd}`);
            // Broadcast team agent spawned event
            this.config.broadcastToGoal?.(goalId, {
                type: "team_agent_spawned", goalId, sessionId: session.id, role, name: roleName,
            });
            return { sessionId: session.id, worktreePath: worktreeResult?.worktreePath };
        }
        catch (err) {
            // Clean up the orphaned worktree on failure (only if one was created)
            if (worktreeResult && goal.repoPath) {
                try {
                    await cleanupWorktree(goal.repoPath, worktreeResult.worktreePath, branchName, true);
                    console.log(`[team-manager] Cleaned up orphaned worktree after spawnRole failure: ${worktreeResult.worktreePath}`);
                }
                catch (cleanupErr) {
                    console.error(`[team-manager] Failed to clean up orphaned worktree ${worktreeResult.worktreePath}:`, cleanupErr);
                }
            }
            throw err;
        }
    }
    /**
     * Notify the team lead that a worker agent has gone idle.
     * Sends a steer message with task context so the team lead can decide next steps.
     */
    async notifyTeamLead(goalId, workerSessionId, role, agentId) {
        const entry = this.teams.get(goalId);
        if (!entry?.teamLeadSessionId)
            return;
        // Defensive guard: never nudge the team lead about a reviewer session.
        // Reviewer sessions are managed by VerificationHarness; their agent_end
        // is part of the verification flow, not a worker-finished signal.
        const firingAgent = entry.agents.find((a) => a.sessionId === workerSessionId);
        if (firingAgent && (firingAgent.kind === "reviewer" || firingAgent.role === "reviewer")) {
            return;
        }
        const teamLeadSession = this.sessionManager.getSession(entry.teamLeadSessionId);
        if (!teamLeadSession || teamLeadSession.status === "terminated")
            return;
        // Debounce: skip if we notified about this worker in the last 30 seconds
        const now = Date.now();
        const lastNotify = this.lastNotifyTime.get(workerSessionId);
        if (lastNotify && now - lastNotify < 30_000) {
            console.log(`[team-manager] Debounced notification for ${agentId} (last: ${now - lastNotify}ms ago)`);
            return;
        }
        this.lastNotifyTime.set(workerSessionId, now);
        // Note: we no longer suppress notifications when the team lead's last
        // turn errored. SessionManager.enqueuePrompt / deliverLiveSteer now own
        // the error-state policy (implicit unstick up to MAX_CONSECUTIVE_ERROR_TURNS,
        // park afterwards). A single source of truth avoids nudges being
        // silently dropped on the floor.
        // Look up tasks assigned to the worker
        const tasks = this.resolveTasksForSession(goalId, workerSessionId);
        let message;
        if (tasks.length > 0) {
            const taskSummaries = tasks.map(t => {
                let s = `"${t.title}" (state: ${t.state})`;
                if (t.resultSummary)
                    s += ` — ${t.resultSummary}`;
                return s;
            }).join("; ");
            message = `Agent ${agentId} (${role}) has finished. Tasks: ${taskSummaries}. Check task details and decide next steps.`;
        }
        else {
            message = `Agent ${agentId} (${role}) has finished with no assigned tasks. Check tasks and decide next steps.`;
        }
        try {
            if (teamLeadSession.status === "streaming") {
                // Mid-turn: inject directly as a real-time steer interrupt
                await this.sessionManager.deliverLiveSteer(entry.teamLeadSessionId, message, { source: "auto-nudge" });
            }
            else {
                // Idle: enqueue as a steered prompt so it drains immediately
                this.sessionManager.enqueuePrompt(entry.teamLeadSessionId, message, { isSteered: true, source: "auto-nudge" });
            }
            console.log(`[team-manager] Notified team lead for goal ${goalId} (status=${teamLeadSession.status}): ${message}`);
        }
        catch (err) {
            console.error(`[team-manager] Failed to notify team lead for goal ${goalId}:`, err);
        }
    }
    /**
     * Notify the team lead when a task transitions to a terminal state.
     * Called from the task transition REST endpoint so the team lead wakes up
     * even if the worker continues with another task without going idle.
     */
    notifyTeamLeadOfTaskCompletion(goalId, taskTitle, taskState) {
        const entry = this.teams.get(goalId);
        if (!entry?.teamLeadSessionId)
            return;
        const teamLeadSession = this.sessionManager.getSession(entry.teamLeadSessionId);
        if (!teamLeadSession || teamLeadSession.status === "terminated")
            return;
        const message = `Task "${taskTitle}" transitioned to ${taskState}. Use task_list for result summaries and gate_status for verification details.`;
        if (teamLeadSession.status === "streaming") {
            this.sessionManager.deliverLiveSteer(entry.teamLeadSessionId, message, { source: "task-notification" }).catch((err) => {
                console.error(`[team-manager] Failed to steer team lead on task completion for goal ${goalId}:`, err);
            });
        }
        else {
            this.sessionManager.enqueuePrompt(entry.teamLeadSessionId, message, { isSteered: true, source: "task-notification" });
        }
        console.log(`[team-manager] Notified team lead of task completion for goal ${goalId}: ${taskTitle} → ${taskState}`);
    }
    /**
     * Dismiss (terminate) a role agent session and clean up its worktree.
     */
    async dismissRole(sessionId) {
        const goalId = this.sessionToGoal.get(sessionId);
        if (!goalId) {
            return false;
        }
        const entry = this.teams.get(goalId);
        if (!entry) {
            return false;
        }
        // Don't allow dismissing the team lead via this method
        if (entry.teamLeadSessionId === sessionId) {
            throw new Error("Cannot dismiss the team lead — use completeTeam() instead");
        }
        const agentIndex = entry.agents.findIndex((a) => a.sessionId === sessionId);
        if (agentIndex === -1) {
            return false;
        }
        const agent = entry.agents[agentIndex];
        // Unsubscribe from agent_end events before terminating
        if (agent.unsubscribeEvent) {
            agent.unsubscribeEvent();
        }
        // Persist repoPath and branch before archiving so worktree can be cleaned up later
        const goal = this.resolveGoal(goalId);
        if (goal?.repoPath && agent.worktreePath) {
            this.sessionManager.updateSessionMeta(sessionId, {
                worktreePath: agent.worktreePath,
            });
            // Store repoPath and branch in the session store for later purge cleanup
            const projectCtx = goalId && this.config.projectContextManager
                ? this.config.projectContextManager.getContextForGoal(goalId)
                : null;
            if (projectCtx) {
                projectCtx.sessionStore.update(sessionId, { repoPath: goal.repoPath, branch: agent.branch });
            }
        }
        // Terminate the session
        await this.sessionManager.terminateSession(sessionId);
        // Worktree is preserved for archived session review — cleanup happens at purge time
        // Remove from tracking
        entry.agents.splice(agentIndex, 1);
        this.sessionToGoal.delete(sessionId);
        this.lastNotifyTime.delete(sessionId);
        this.persistEntry(goalId);
        // If no workers remain and team lead is idle, restart timers so the
        // 5-minute no-workers nudge fires from this point.
        if (entry.agents.length === 0) {
            const tlSession = entry.teamLeadSessionId
                ? this.sessionManager.getSession(entry.teamLeadSessionId)
                : null;
            if (tlSession && tlSession.status === "idle") {
                this.startIdleNudgeTimer(goalId);
            }
            else {
                this.clearIdleNudgeTimer(goalId);
            }
        }
        console.log(`[team-manager] Dismissed ${agent.role} agent (${sessionId}) for goal ${goalId}`);
        // Broadcast team agent dismissed event
        const sessionMeta = this.sessionManager.getSession(sessionId);
        const dismissedName = sessionMeta?.title || `${agent.role}-${sessionId.slice(0, 8)}`;
        this.config.broadcastToGoal?.(goalId, {
            type: "team_agent_dismissed", goalId, sessionId, role: agent.role, name: dismissedName,
        });
        return true;
    }
    /**
     * Register a verification reviewer session as a team agent without creating a worktree.
     * The verification harness manages the session lifecycle — no agent_end subscription is needed.
     * Silently returns if no team exists for the goal (handles manual gate signals).
     */
    registerReviewerSession(goalId, sessionId, stepName) {
        const entry = this.teams.get(goalId);
        if (!entry)
            return; // No active team — skip registration silently
        const agent = {
            sessionId,
            role: 'reviewer',
            kind: "reviewer",
            worktreePath: undefined,
            branch: undefined,
            task: `Verification review: ${stepName}`,
            createdAt: Date.now(),
        };
        entry.agents.push(agent);
        this.sessionToGoal.set(sessionId, goalId);
        this.assignUniqueColor(sessionId);
        this.persistEntry(goalId);
    }
    /**
     * Unregister a verification reviewer session from the team.
     * Called after the session is terminated so archiving happens first.
     */
    unregisterReviewerSession(goalId, sessionId) {
        const entry = this.teams.get(goalId);
        if (!entry)
            return;
        const idx = entry.agents.findIndex(a => a.sessionId === sessionId);
        if (idx !== -1) {
            const agent = entry.agents[idx];
            if (agent.unsubscribeEvent)
                agent.unsubscribeEvent();
            entry.agents.splice(idx, 1);
        }
        this.sessionToGoal.delete(sessionId);
        this.persistEntry(goalId);
    }
    /**
     * List all active agents for a goal.
     */
    listAgents(goalId) {
        const entry = this.teams.get(goalId);
        if (!entry) {
            return [];
        }
        return entry.agents.map((agent) => {
            const session = this.sessionManager.getSession(agent.sessionId);
            return {
                sessionId: agent.sessionId,
                role: agent.role,
                status: session?.status ?? "terminated",
                worktreePath: agent.worktreePath,
                branch: agent.branch,
                task: agent.task,
                createdAt: agent.createdAt,
            };
        });
    }
    /**
     * Find a team agent by session ID across all goals.
     * Returns the TeamAgent record if found, undefined otherwise.
     */
    findAgentBySessionId(sessionId) {
        const goalId = this.sessionToGoal.get(sessionId);
        if (!goalId)
            return undefined;
        const entry = this.teams.get(goalId);
        if (!entry)
            return undefined;
        return entry.agents.find(a => a.sessionId === sessionId);
    }
    /**
     * Complete a team: dismiss all role agents but keep the team lead alive.
     * The team lead remains active to await further instructions.
     */
    async completeTeam(goalId) {
        const entry = this.teams.get(goalId);
        if (!entry) {
            throw new Error(`No active team for goal: ${goalId}`);
        }
        // Cancel any in-flight verifications before completing — prevents zombie reviewers
        if (this.verificationHarness) {
            await this.verificationHarness.cancelAllVerifications(goalId);
        }
        // Enforce gate requirements before allowing completion
        const completeGateStore = this.resolveGateStore(goalId);
        const goal = this.resolveGoal(goalId);
        const skipReqs = goal?.skipGateRequirements;
        if (goal?.workflow && completeGateStore && (!skipReqs || !skipReqs.includes("workflow"))) {
            const gateStates = completeGateStore.getGatesForGoal(goalId);
            const passedIds = new Set(gateStates.filter(g => g.status === "passed").map(g => g.gateId));
            const failedGates = goal.workflow.gates.filter(g => !passedIds.has(g.id));
            if (failedGates.length > 0) {
                throw new Error(`Cannot complete: gates not passed: ${failedGates.map(g => g.name).join(", ")}`);
            }
        }
        // Cancel idle-nudge timer and unsubscribe from team lead events
        this.clearIdleNudgeTimer(goalId);
        entry.unsubscribeTeamLeadEvents?.();
        // Dismiss all role agents
        const agentSessionIds = entry.agents.map((a) => a.sessionId);
        for (const sessionId of agentSessionIds) {
            try {
                await this.dismissRole(sessionId);
            }
            catch (err) {
                console.error(`[team-manager] Error dismissing agent ${sessionId} during team completion:`, err);
            }
        }
        // Keep the team lead session alive — do NOT terminate it.
        // The team lead will await further instructions.
        // Update goal state
        await this.resolveGoalManager(goalId).updateGoal(goalId, { state: "complete" });
        // Keep team tracking alive so the team lead can still be found
        // but persist the updated state (agents cleared)
        this.persistEntry(goalId);
        console.log(`[team-manager] Completed team for goal ${goalId} — team lead remains active: ${entry.teamLeadSessionId}`);
    }
    /**
     * Fully tear down a team: dismiss all agents AND terminate the team lead.
     * Use this when explicitly shutting down everything.
     */
    async teardownTeam(goalId) {
        const entry = this.teams.get(goalId);
        if (!entry) {
            throw new Error(`No active team for goal: ${goalId}`);
        }
        // Cancel any in-flight verifications before teardown — prevents zombie reviewers
        if (this.verificationHarness) {
            await this.verificationHarness.cancelAllVerifications(goalId);
        }
        // Cancel idle-nudge timer and unsubscribe from team lead events
        this.clearIdleNudgeTimer(goalId);
        entry.unsubscribeTeamLeadEvents?.();
        // Dismiss all role agents
        const agentSessionIds = entry.agents.map((a) => a.sessionId);
        for (const sessionId of agentSessionIds) {
            try {
                await this.dismissRole(sessionId);
            }
            catch (err) {
                console.error(`[team-manager] Error dismissing agent ${sessionId} during team teardown:`, err);
            }
        }
        // Terminate the team lead session — persist worktree info first so purge can clean up
        if (entry.teamLeadSessionId) {
            const goal = this.resolveGoal(goalId);
            if (goal?.repoPath) {
                const projectCtx = this.config.projectContextManager
                    ? this.config.projectContextManager.getContextForGoal(goalId)
                    : null;
                if (projectCtx) {
                    projectCtx.sessionStore.update(entry.teamLeadSessionId, {
                        repoPath: goal.repoPath,
                        branch: goal.branch,
                        worktreePath: goal.worktreePath,
                    });
                }
            }
            try {
                await this.sessionManager.terminateSession(entry.teamLeadSessionId);
            }
            catch (err) {
                console.error(`[team-manager] Error terminating team lead ${entry.teamLeadSessionId}:`, err);
            }
            this.sessionToGoal.delete(entry.teamLeadSessionId);
        }
        // Remove team tracking entirely
        this.teams.delete(goalId);
        this.resolveTeamStore(goalId).remove(goalId);
        console.log(`[team-manager] Tore down team for goal ${goalId}`);
    }
    /**
     * Get the full team state for a goal.
     */
    getTeamState(goalId) {
        const entry = this.teams.get(goalId);
        if (!entry) {
            return undefined;
        }
        return {
            goalId: entry.goalId,
            teamLeadSessionId: entry.teamLeadSessionId,
            agents: this.listAgents(goalId),
            maxConcurrent: entry.maxConcurrent,
        };
    }
}
//# sourceMappingURL=team-manager.js.map