/**
 * Hook dispatcher: turns a runtime `Event` + loaded packs into a Claude Code
 * hook decision (`exit code 0 = allow | 2 = block` + optional stderr message).
 *
 * Sits between the per-host hook binaries (`pre-tool-use.ts`, `stop.ts`,
 * `user-prompt-submit.ts`, `session-end.ts`) and the rule evaluator. Each hook
 * binary is a thin shell that:
 *   1. Reads stdin → parses into an Event of the right `kind`.
 *   2. Loads active packs + builds the function registry (bootstrap.ts).
 *   3. Calls `dispatchEvent` to produce `{ exitCode, stderr }`.
 *   4. Writes stderr, exits with the code.
 *
 * The dispatcher walks `packs × skills × rules`, runs each rule's process via
 * `evaluateProcess`, and short-circuits on the FIRST verdict. The first-match
 * semantics matter: a high-priority pack's block decision must not be
 * overridden by a later pack's warn. Pack ordering is the loader's
 * responsibility (Task 1.19); the dispatcher trusts the order it's given.
 *
 * AUTO.1 event-kind filter: before walking a skill's rules, the dispatcher
 * checks `event.kind ∈ skill.triggers.map(t => t.kind)`. Skills that don't
 * subscribe to the incoming event kind are skipped entirely. This keeps
 * tool-call hooks from re-running schedule/webhook/file-changed skills and
 * vice versa. A skill that wants to fire on multiple kinds lists them all
 * (e.g. `triggers: [{kind: tool_call}, {kind: schedule, cron: ...}]`). The
 * default trigger list (when the YAML block is omitted) is a single
 * `tool_call` entry — preserves Phase 1–7 dispatcher behavior verbatim.
 *
 * Drift-response policy resolution:
 *   1. Pack-shipped `drift_response.yaml` → `Pack.driftResponse` (loaded by
 *      `loadPack`). For each fired rule, the dispatcher consults
 *      `pack.driftResponse?.per_rule[rule.id] ?? pack.driftResponse?.default`.
 *   2. Pack without `drift_response.yaml` → falls back to `defaultPolicyForLevel`,
 *      which DERIVES the policy from the fired verdict's `level` (block →
 *      block_tool; warn/surface/pass/directive → warn). This honors the
 *      author's `level:` by default — a `level: warn` rule warns instead of
 *      hard-blocking — so a policy file is an OVERRIDE, not a prerequisite for
 *      level-correct behavior. (Replaces the old blanket `block_tool` default,
 *      which discarded the level.)
 *   3. `corrective_skills` map (`auto_correct` policy support) is threaded
 *      into `applyDriftResponse` via the `DriftDispatchCtx` argument so
 *      `auto_correct` policies can look up their corrective skill name.
 *
 * Pack-agnostic discipline: the dispatcher does NOT special-case
 * sangmin-personal-rules or any other named pack. It reads whatever each
 * pack's `driftResponse` field contains; missing/empty/absent all flow
 * through the same `??` chain.
 *
 * Exit-code mapping (Claude Code hook protocol):
 *   block_tool   → { exitCode: 2, stderr: message }
 *   warn         → { exitCode: 0, stderr: message }   (allow, but surface)
 *   halt         → { exitCode: 0, stderr: '' }        (Task 1.14 wires real halt)
 *   notify_pause → { exitCode: 0, stderr: '' }        (Task 1.18 wires channels)
 *
 * Halt and notify_pause exit 0 deliberately for Phase 1: the runtime can't
 * actually halt the parent agent's task loop from a hook, and channel
 * notifications need infrastructure that doesn't ship until later phases.
 * Mapping them to exit 0 means a misconfigured pack-declared policy won't
 * silently block tools during Phase 1; the real behavior lands when its
 * machinery does.
 *
 * Imports from: runtime/types.js, runtime/evaluator.js, runtime/drift_response.js,
 * functions/registry.js.
 * Imported by: runtime/hooks/*.ts (per-hook binaries).
 */
import { applyDriftResponse, defaultPolicyForLevel } from '../drift_response.js';
import { evaluateProcess } from '../evaluator.js';
import { Matcher, matchesEvent } from '../load_matchers.js';
import { partitionSkills } from '../pinned_skills.js';
import { advanceSkillTicks } from '../session_state.js';
import { RequiresCache, skillRequiresHold } from '../skill_requires.js';
import { UnloadCondition, shouldUnload } from '../unload_conditions.js';
import { formatProfessionError, resolveProfessionDirective } from './profession_resolver.js';
/**
 * G.2 dispatch-trace marker.
 *
 * Emits `[opensquid-dispatch] event=<kind> rules=<N> packs=<N>` to STDERR at
 * the END of every `dispatchEvent` call. Three reasons stderr (not stdout):
 *   1. Claude Code parses stdout as JSON for some hook events; polluting it
 *      with diagnostic text would break the host contract.
 *   2. Claude Code only surfaces stderr to the user when exit code is non-zero
 *      (per the hook contract); on exit 0 the marker stays invisible to the
 *      user but visible to CI / `opensquid doctor hooks` subprocess probes.
 *   3. Matches the convention opensquid uses elsewhere in stderr.
 *
 * Default-on; users can silence with `OPENSQUID_DISPATCH_TRACE=0`. The
 * absence of this marker line in a subprocess probe is the load-bearing
 * signal that the hook bin silently no-op'd (the G.1 root-cause failure
 * mode). See `hooks.bin.integration.test.ts` + `src/setup/cli/doctor.ts`.
 */
function emitDispatchMarker(eventKind, ruleCount, packCount) {
    if (process.env.OPENSQUID_DISPATCH_TRACE === '0')
        return;
    process.stderr.write(`[opensquid-dispatch] event=${eventKind} rules=${String(ruleCount)} packs=${String(packCount)}\n`);
}
// ---------------------------------------------------------------------------
// CU.2 — skill-unload wiring helpers.
//
// `when_to_load` / `unloads_when` are `unknown[]` on the runtime `Skill` type
// (the YAML schema validates them at load; the runtime view stays loose — see
// types.ts). We parse them through the canonical Zod schemas here, dropping any
// malformed entry rather than throwing, so a single bad matcher/condition
// degrades that one entry, never the whole dispatch.
// ---------------------------------------------------------------------------
/** Parse a skill's `when_to_load` (loose `unknown[]`) into typed matchers; drop malformed. */
function parseWhenToLoad(raw) {
    const out = [];
    for (const r of raw) {
        const parsed = Matcher.safeParse(r);
        if (parsed.success)
            out.push(parsed.data);
    }
    return out;
}
/** Parse a skill's `unloads_when` (loose `unknown[]`) into typed conditions; drop malformed. */
function parseUnloadsWhen(raw) {
    const out = [];
    for (const r of raw) {
        const parsed = UnloadCondition.safeParse(r);
        if (parsed.success)
            out.push(parsed.data);
    }
    return out;
}
export function inboundChannelTriggerMatches(trigger, event) {
    if (trigger.channel !== undefined && trigger.channel.length > 0) {
        const m = /^(telegram|slack|discord):\/\//.exec(event.channelUri);
        const platform = m === null ? null : m[1];
        if (platform !== trigger.channel)
            return false;
    }
    if (trigger.sender_pattern !== undefined && trigger.sender_pattern.length > 0) {
        let re;
        try {
            re = new RegExp(trigger.sender_pattern);
        }
        catch {
            return false;
        }
        if (!re.test(event.sender))
            return false;
    }
    return true;
}
export function activationScopeApplies(scope, ctx) {
    switch (scope) {
        case 'project':
            return ctx.inProject;
        case 'user':
            return ctx.isUserSession;
        case 'hybrid':
            return ctx.inProject && ctx.isUserSession;
        case 'team':
            return false;
        case 'global':
            return ctx.isUserSession;
    }
}
export function shouldSkillUnload(skill, tick) {
    if (tick === undefined)
        return false; // fail-safe: no tick → stay loaded
    const conditions = parseUnloadsWhen(skill.unloads_when);
    if (conditions.length === 0)
        return false; // no exit conditions → never unloads
    return shouldUnload(conditions, tick);
}
/**
 * Advance the persisted per-skill ticks for the dynamic skill set ONCE for this
 * event (never per-rule — that would multiply the idle count), then return the
 * set of dynamic skill names to SKIP because their unload condition fired.
 *
 * Reactivation: a dynamic skill whose `when_to_load` freshly matches this event
 * has its tick reset (idle counter restarts from zero) — so a skill that had
 * unloaded re-loads the moment its load condition matches again. A skill with
 * empty `when_to_load` never reactivates here; it simply advances and unloads
 * on its idle/edge conditions.
 */
async function computeUnloadSkips(event, dynamic, sessionId) {
    const dynamicIds = dynamic.map((s) => s.name);
    const reactivated = new Set();
    for (const s of dynamic) {
        if (matchesEvent(parseWhenToLoad(s.when_to_load), event))
            reactivated.add(s.name);
    }
    const ticks = await advanceSkillTicks(sessionId, event, dynamicIds, reactivated);
    const skip = new Set();
    for (const s of dynamic) {
        if (shouldSkillUnload(s, ticks[s.name]))
            skip.add(s.name);
    }
    return { skip, ticks };
}
export async function dispatchEvent(event, packs, registry, sessionId, scopeCtx = { inProject: true, isUserSession: true }) {
    // Count rules walked across all skills so the marker carries a meaningful
    // signal (rules=0 means dispatch ran but no skill subscribed to this kind;
    // rules=N means N rules were evaluated up to the short-circuit point).
    let rulesWalked = 0;
    // G.4 — aggregate inject_context payloads across every rule in every
    // skill. Kept across the full walk (verdict short-circuit still returns
    // any injections that fired BEFORE the verdict — by-design coexistence).
    const contextInjections = [];
    // T-ASC ASC.3 — peer to contextInjections. Directives emitted on any
    // event kind aggregate here; only the UserPromptSubmit hook bin actually
    // surfaces them via the additionalContext envelope. Other hook bins
    // discard with a warning (same posture as inject_context).
    const directives = [];
    // Stderr warnings buffer when an `inject_context` (or `directive`) fires
    // on an event kind OTHER than `prompt_submit`. Misconfiguration signal
    // per Phase-2 lock #4.
    let warnBuf = '';
    // CU.2 — skill-unload gate. Partition pinned (universal+preload, resident
    // for the whole session) vs dynamic (subject to `unloads_when`). Advance the
    // persisted dynamic-skill ticks ONCE for this event (before the walk, never
    // per-rule) and compute the set of dynamic skills whose unload condition
    // fired this event. Pinned skills are NEVER gated — they are not even passed
    // to the tick advancer (the contradiction warning is emitted at partition
    // time by `partitionSkills`). A dynamic skill in `unloadSkip` has both its
    // rules suppressed AND its `inject_context` prose dropped (the `continue`
    // below skips the whole rule walk, so nothing it would emit lands).
    const { dynamic } = partitionSkills(packs);
    const dynamicSkillNames = new Set(dynamic.map((d) => d.skill.name));
    const { skip: unloadSkip } = await computeUnloadSkips(event, dynamic.map((d) => d.skill), sessionId);
    // T-ASC ASC.2: per-fire precondition cache. ONE instance per dispatchEvent
    // call (NOT module-level — hook bins are short-lived processes; per-call
    // scope amortizes within one fire across N skills sharing a precondition).
    const requiresCache = new RequiresCache();
    for (const pack of packs) {
        // IDF.4 — activation_scope dispatch routing. `pack.activationScope ??
        // 'project'` covers IDF.1's optional Pack runtime field for test
        // fixtures + back-compat. A scope mismatch (e.g. user-context-only pack
        // when scopeCtx.inProject === false) skips the entire skill walk.
        if (!activationScopeApplies(pack.activationScope ?? 'project', scopeCtx))
            continue;
        for (const skill of pack.skills) {
            // CU.2: a DYNAMIC skill whose unload condition fired this event is
            // skipped — its rules don't evaluate and its prose isn't injected.
            // Pinned skills (not in `dynamicSkillNames`) are exempt and always walk.
            if (dynamicSkillNames.has(skill.name) && unloadSkip.has(skill.name))
                continue;
            // AUTO.1: skip the skill entirely if no trigger subscribes to this
            // event kind. `skill.triggers` is guaranteed non-empty by the schema
            // (`.min(1)`) — an omitted YAML block defaults to `[{kind: 'tool_call'}]`.
            // Using `.some` rather than `.includes` because each trigger is a
            // discriminated-union object, not a bare string.
            if (!skill.triggers.some((t) => t.kind === event.kind))
                continue;
            // LL.3 (2026-05-30) — inbound_channel triggers can carry an
            // optional sender_pattern filter (compiled as JS RegExp at
            // dispatch; malformed → silent skip). When the event is an
            // inbound_channel event and ANY matching-kind trigger has a
            // sender_pattern that doesn't match the event sender, the skill
            // is skipped. Empty sender_pattern → accept-all (back-compat).
            // The channel: literal field (e.g. 'telegram') is also honored:
            // it must match the scheme prefix of event.channelUri.
            if (event.kind === 'inbound_channel') {
                const matchingTriggers = skill.triggers.filter((t) => t.kind === 'inbound_channel');
                const anyMatch = matchingTriggers.some((t) => inboundChannelTriggerMatches(t, event));
                if (!anyMatch)
                    continue;
            }
            // T-ASC ASC.2: AND-precondition gate at the dispatcher boundary. Slots
            // AFTER the trigger filter (so a wrong-kind event short-circuits before
            // any stat) and BEFORE the rule walk (so rule-local guards never see a
            // skill that the dispatcher already deactivated). Empty `requires:`
            // trivially holds — back-compat with every Phase 1+ pack. Per L5
            // posture: any non-ENOENT stat error inside the evaluator is treated as
            // engaged (the gate walks rules), never silently disabled.
            if (skill.requires.length > 0) {
                const hold = await skillRequiresHold(skill.requires, sessionId, requiresCache);
                if (!hold)
                    continue;
            }
            for (const rule of skill.rules) {
                // Phase 4: destination_check rules fire on the scheduler tick
                // (`destination_scheduler.ts` → `check_destination` primitive), not
                // through the per-event process walker. Skip them here so the
                // dispatcher only walks track_check processes.
                if (rule.kind === 'destination_check')
                    continue;
                // T-ASC ASC.5: per-rule requires evaluated AFTER skill-level
                // requires and BEFORE walking the process. Same RequiresCache so
                // skill-level + per-rule preconditions stat shared files once per
                // fire. Empty rule.requires trivially holds (back-compat).
                if (rule.requires.length > 0) {
                    const hold = await skillRequiresHold(rule.requires, sessionId, requiresCache);
                    if (!hold)
                        continue;
                }
                rulesWalked += 1;
                const ctx = {
                    event,
                    bindings: new Map(),
                    sessionId,
                    packId: pack.name,
                    // PR-followup: thread pack-shipped `models.yaml` into eval context
                    // so LLM primitives can resolve aliases from the pack's declared
                    // baseline. Spread-conditional to keep `EvalCtx` clean for packs
                    // that ship no `models.yaml` (`exactOptionalPropertyTypes` rejects
                    // explicit `undefined` on an optional slot).
                    ...(pack.models !== undefined ? { packModels: pack.models } : {}),
                    // Slice A3b: thread the pack's declared lifecycle FSM so the
                    // read_fsm_state / advance_fsm primitives can read + advance it.
                    ...(pack.fsm !== undefined ? { packFsm: pack.fsm } : {}),
                };
                const result = await evaluateProcess(rule.process, ctx, registry);
                // G.4 — `inject_context` is a non-blocking terminal RuleResult.
                // Aggregate and continue walking; only `UserPromptSubmit` hook bin
                // actually emits the array. Other hook bins discard with a warning.
                if (result.kind === 'inject_context') {
                    // HH6.1 (2026-05-31): inject_context now surfaces on BOTH
                    // prompt_submit (UPS bin) AND session_start (session-start bin) —
                    // each bin reads `contextInjections` and emits the host's
                    // `additionalContext` envelope. Any OTHER event kind still drops
                    // with a warning (no bin surfaces it). The set of surfacing kinds
                    // is intentionally explicit so a misrouted trigger stays visible.
                    if (event.kind === 'prompt_submit' || event.kind === 'session_start') {
                        contextInjections.push(result.content);
                    }
                    else {
                        warnBuf +=
                            `[opensquid] WARN: rule "${rule.id}" in skill "${skill.name}" (pack "${pack.name}") ` +
                                `emitted inject_context on event kind "${event.kind}"; only "prompt_submit" / ` +
                                `"session_start" surface injections (drop). Update the skill's triggers: block.\n`;
                    }
                    continue;
                }
                // T-ASC ASC.3 — `directive` is a non-blocking terminal RuleResult.
                // Aggregate and continue (mirrors inject_context). Only the
                // UserPromptSubmit hook bin emits these via the envelope; other
                // hook bins warn + drop.
                if (result.kind === 'directive') {
                    if (event.kind === 'prompt_submit') {
                        // MM.2 (2026-05-30) — validate profession directives against the
                        // loaded pack registry + each pack's loaded team.yaml. Invalid
                        // directives are DROPPED (not emitted to the agent) + the reason
                        // is logged to stderr. Skill + tool directives pass through.
                        const na = result.directive.next_action;
                        if (na.profession !== undefined) {
                            const teamsByPack = new Map();
                            for (const p of packs) {
                                if (p.team !== undefined)
                                    teamsByPack.set(p.name, p.team);
                            }
                            const resolved = resolveProfessionDirective(na, packs, teamsByPack);
                            if (!resolved.ok) {
                                warnBuf +=
                                    `[opensquid] WARN: dropping invalid profession directive — ` +
                                        `${formatProfessionError(resolved.reason)} (rule "${rule.id}" in skill ` +
                                        `"${skill.name}" pack "${pack.name}")\n`;
                                continue;
                            }
                        }
                        directives.push({ ...result.directive, ruleId: rule.id });
                    }
                    else {
                        warnBuf +=
                            `[opensquid] WARN: rule "${rule.id}" in skill "${skill.name}" (pack "${pack.name}") ` +
                                `emitted a directive on event kind "${event.kind}"; only "prompt_submit" surfaces ` +
                                `directives (drop). Update the skill's triggers: block.\n`;
                    }
                    continue;
                }
                if (result.kind !== 'verdict')
                    continue;
                // Resolve drift-response policy from the pack's `drift_response.yaml`
                // (folded into `Pack.driftResponse` by `loadPack`). Precedence:
                // per-rule override → pack default → level-derived fallback
                // (`defaultPolicyForLevel`). The fallback honors the verdict's authored
                // `level:` (block→block_tool, else→warn) for packs that ship no file —
                // so a `level: warn` rule warns by default instead of hard-blocking.
                //
                // `rule.id` is required on every rule by the Skill schema, so the
                // `per_rule` lookup is always well-defined.
                const driftResponse = pack.driftResponse;
                const resolvedPolicy = driftResponse?.per_rule[rule.id] ??
                    driftResponse?.default ??
                    defaultPolicyForLevel(result.verdict.level);
                // `corrective_skills` is only consulted by the `auto_correct` policy
                // inside `applyDriftResponse`; passing it unconditionally is safe
                // because the dispatcher ignores it for every other policy.
                const action = applyDriftResponse(result.verdict, resolvedPolicy, {
                    ...(driftResponse !== undefined
                        ? { correctiveSkills: driftResponse.corrective_skills }
                        : {}),
                });
                switch (action.kind) {
                    case 'block_tool':
                        emitDispatchMarker(event.kind, rulesWalked, packs.length);
                        return {
                            exitCode: 2,
                            stderr: appendWarn(action.message, warnBuf),
                            contextInjections,
                            directives,
                        };
                    case 'warn':
                        // FU.8: a non-blocking verdict (exit 0) MUST NOT short-circuit the
                        // walk. Buffer its message and CONTINUE, so a LATER pack's
                        // side-effect advance (advance_fsm / write_state) still runs. Only
                        // the exit-2 verdicts (block_tool / halt) return early — the tool
                        // aborts anyway. Buffered warns surface at the final return (or get
                        // appended to a later block's message). Fixes the bug where a
                        // higher-precedence warn (e.g. scope-architect's pre-research-authoring
                        // DPC.5 warn) stalled coding-flow's FSM advance at `idle`.
                        warnBuf += `${action.message}\n`;
                        break;
                    case 'halt':
                        // T-RJ-FOLLOWUPS FU.9: `full_stop_and_redo` → `halt` now ENFORCES
                        // as a hard block (exit 2) instead of the old exit-0 stub that made
                        // every full_stop_and_redo gate (the commit / versioning /
                        // workflow-phases gates + default-discipline's `default`) silently
                        // no-op. A hook can't literally halt the agent's loop, so "stop and
                        // redo" = block the drift action + surface the verdict's message
                        // (the directive — e.g. "log each phase before committing"). The
                        // destructive chain-state/ledger reset to `entrySkill` is an opt-in
                        // `restart` action (FU.10), NOT applied here — an incomplete-phases
                        // commit just needs the agent to finish the phases, not a wipe.
                        emitDispatchMarker(event.kind, rulesWalked, packs.length);
                        return {
                            exitCode: 2,
                            stderr: appendWarn(action.reason, warnBuf),
                            contextInjections,
                            directives,
                        };
                    case 'notify_pause':
                        // T-RJ-FOLLOWUPS FU.10: `notify_and_pause` now SURFACES its message
                        // (exit 0 + stderr) instead of the old exit-0 + EMPTY stub that
                        // silently dropped it. A hook can't truly pause the agent loop
                        // (exit 0/2 + stderr is the only lever), so "notify + pause" = surface
                        // the reminder so the agent/user acts on it. The ONLY consumer,
                        // `version-slot-assignment`, is a prompt_submit/warn reminder that
                        // minor/major version slots need user authorization — the actual
                        // BLOCK of an unauthorized bump is the companion `tool_call` rule
                        // `versioning-pre1-patch-only` (halt → exit 2, FU.9). Exit-2 here
                        // would wrongly block the user's prompt. Also surfaces the
                        // auto_correct-degrade + unknown-policy fail-safe reasons.
                        // FU.8: non-blocking → buffer + continue (see the `warn` case).
                        warnBuf += `${action.reason}\n`;
                        break;
                    case 'auto_correct':
                    case 'escalate':
                        // No pack rule maps to these policies yet — safe exit-0 + empty stub
                        // so a future pack-declared verdict doesn't accidentally block. When
                        // a rule opts in, wire `runtime/auto_correct.ts` / `runtime/escalate.ts`
                        // (their side-effect layers) from here; building now is speculative
                        // (FU.10's "build when a rule opts in" gate). The destructive
                        // `restart` action stays unbuilt for the same reason.
                        // FU.8: non-blocking → continue (see the `warn` case).
                        break;
                }
            }
        }
    }
    emitDispatchMarker(event.kind, rulesWalked, packs.length);
    return { exitCode: 0, stderr: appendWarn('', warnBuf), contextInjections, directives };
}
/**
 * Append the buffered inject-context misuse warnings to whatever stderr the
 * dispatcher otherwise produced. Keeps the warning visible alongside a
 * block / warn message rather than getting swallowed by the verdict
 * short-circuit. Empty inputs short-circuit to '' (no spurious newline).
 */
function appendWarn(stderr, warnBuf) {
    if (warnBuf === '')
        return stderr;
    if (stderr === '')
        return warnBuf.trimEnd();
    return `${stderr}\n${warnBuf.trimEnd()}`;
}
//# sourceMappingURL=dispatch.js.map