/**
 * PromiseBeacon — Follow-through heartbeats for open agent commitments.
 *
 * Implements the Phase 1 scope of docs/specs/PROMISE-BEACON-SPEC.md.
 *
 * When the agent says "I'll come back when X finishes" and then works silently
 * for 30+ minutes, the user has no signal whether the agent is alive,
 * progressing, or has forgotten. PromiseBeacon watches beacon-enabled
 * commitments (Commitment rows with `beaconEnabled: true` and a `topicId`)
 * and emits a short status line on a per-commitment cadence.
 *
 * Key properties:
 *  - setTimeout-based scheduling (not polling); durable across sleep by
 *    persisting `nextDueAt` (spec Round 3 #17).
 *  - Per-commitment hot state at .instar/state/promise-beacon/<id>.json
 *    (gitignored).
 *  - Snapshot-hash gate: unchanged tmux output emits a templated line
 *    without calling the LLM (≈70% of heartbeats on a quiet session).
 *  - Session-epoch check: if the Claude Code session UUID at declaration
 *    no longer matches the live session, transition to `violated` with
 *    reason `session-lost`.
 *  - Quiet hours + daily spend cap → `beaconSuppressed` (non-terminal).
 *  - Shares the LlmQueue and ProxyCoordinator with PresenceProxy so the
 *    two monitors can't double-post.
 *
 * Integration points:
 *  - `commitmentTracker.mutate(id, fn)` is the only write path.
 *  - `sendMessage(topicId, text, { source: 'promise-beacon', isProxy: true })`
 *    is what PresenceProxy's `isSystemOrProxyMessage` filters out.
 */
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { EventEmitter } from 'node:events';
import { LlmAbortedError } from './LlmQueue.js';
import { sanitizeTmuxOutput, guardProxyOutput } from './PresenceProxy.js';
import { detectInternalIdLeak } from '../core/internal-id-leak.js';
// Rotating templated phrases (spec Round 3 #9).
const TEMPLATED_VARIANTS = [
    'still on it, no new output since last update',
    'still working — snapshot unchanged since last beat',
    'holding steady on this task — no fresh output yet',
    'continuing; terminal quiet since last check-in',
    'alive, working — no visible change since last heartbeat',
];
const AT_RISK_VARIANTS = [
    'no observable progress — may be waiting on external input',
    'still watching; appears idle — flagging at-risk',
    'no recent output; continuing to monitor',
];
// ─── Helper: snapshot normalization before hashing (spec §P14 + #16a) ──────
function normalizeForHash(raw) {
    let s = raw;
    // Strip ANSI CSI.
    s = s.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
    // Control chars except \n \t.
    s = s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
    // Spinners → placeholder.
    s = s.replace(/[\u2807\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F]/g, '·');
    // Timestamps → [TS].
    s = s.replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?\b/g, '[TS]');
    s = s.replace(/\b\d{1,2}:\d{2}:\d{2}\b/g, '[TS]');
    // Progress counters.
    s = s.replace(/\b\d+(?:\.\d+)?\s?%\b/g, '[PROG]');
    s = s.replace(/\b\d+\/\d+\s+bytes\b/g, '[PROG]');
    s = s.replace(/\biter[=\s]\d+\b/gi, '[CTR]');
    // Trailing whitespace + blank lines collapse.
    s = s.split('\n').map(l => l.replace(/\s+$/, '')).filter((_l, i, arr) => {
        // Collapse >1 consecutive blank lines.
        return !(i > 0 && arr[i] === '' && arr[i - 1] === '');
    }).join('\n');
    return s.trim();
}
function sha256(s) {
    return crypto.createHash('sha256').update(s).digest('hex');
}
/**
 * Build a short promise-text excerpt to suffix on heartbeat messages so the
 * user can tell *which* watched promise this heartbeat is about.
 *
 * Prefers `agentResponse` (what the agent actually said it would do),
 * falls back to `userRequest`. Strips newlines, collapses whitespace,
 * truncates with an ellipsis at ~80 chars on a word boundary.
 *
 * Returns an empty string if no usable text is available (defensive only —
 * a commitment without either field is malformed).
 */
function promiseExcerpt(c) {
    const raw = (c.agentResponse || c.userRequest || '').replace(/\s+/g, ' ').trim();
    if (!raw)
        return '';
    const MAX = 80;
    let excerpt;
    if (raw.length <= MAX) {
        excerpt = raw;
    }
    else {
        const cut = raw.slice(0, MAX);
        const lastSpace = cut.lastIndexOf(' ');
        const boundary = lastSpace > 40 ? lastSpace : MAX;
        excerpt = cut.slice(0, boundary) + '…';
    }
    // HONEST-PROGRESS-MESSAGING B5 / FD-7 — the excerpt derives from LLM- or
    // user-originated commitment text and is embedded in every user-facing beacon
    // surface. Run it through the same proxy guard applied to tmux-derived status
    // lines; unsafe content falls back to a neutral placeholder.
    const guard = guardProxyOutput(excerpt);
    return guard.safe ? excerpt : 'this task';
}
// Human-friendly remaining-time string for the deadline-pressure line (B1a).
function humanizeMs(ms) {
    const mins = Math.max(1, Math.round(ms / 60_000));
    if (mins < 60)
        return `${mins} min`;
    const hours = Math.floor(mins / 60);
    const rem = mins % 60;
    return rem > 0 ? `${hours}h ${rem}m` : `${hours}h`;
}
/**
 * Redact secret-shaped content from a Rung-2 excerpt (I10). Drops the excerpt
 * entirely (→ generic phrasing) if it contains an API-key / token / password
 * signature; otherwise returns it unchanged.
 */
function redactSecrets(excerpt) {
    if (!excerpt)
        return '';
    const SECRET = /\b(sk-[A-Za-z0-9]{12,}|ghp_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{12,}|(?:secret|password|passwd|api[_-]?key|token|bearer)\b\s*[:=]?\s*\S{6,})/i;
    return SECRET.test(excerpt) ? '' : excerpt;
}
/**
 * The ONE approved Rung-2 message per recoverability state (§3.2). Wording is
 * conditional on what's actually true — never a false "still working", never a
 * resume promise the agent can't keep. `'disabled'` emits no user message.
 */
function rung2Message(state, excerpt) {
    const re = excerpt ? ` *${excerpt}*` : ' an action I promised you';
    switch (state) {
        case 'retryable':
            return `⏳ Still on${re} — my session ended; I'm picking it back up.`;
        case 'owner-gone':
            return `⏳ Still open:${re}. My session ended and I can't auto-resume this right now — an operator may need to step in.`;
        case 'quota-limited':
            return `⏳ Still open:${re}. Paused while I'm at capacity; I'll resume when there's headroom.`;
        case 'operator-needed':
            return `⚠️ I couldn't get back to${re} automatically and have flagged it for attention.`;
        case 'disabled':
        default:
            return null;
    }
}
// Cap tmux capture (spec #16b).
function capOutput(raw, maxBytes = 4096, maxLines = 200) {
    const lines = raw.split('\n').slice(-maxLines);
    let out = lines.join('\n');
    if (Buffer.byteLength(out, 'utf8') > maxBytes) {
        out = out.slice(-maxBytes);
    }
    return out;
}
// ─── Main class ─────────────────────────────────────────────────────────────
export class PromiseBeacon extends EventEmitter {
    config;
    started = false;
    stateDir;
    timers = new Map();
    /** C1+C2 §4.4 — the periodic external-block staleness governor sweep timer. */
    externalBlockSweepTimer;
    prefix;
    minCadenceMs;
    maxCadenceMs;
    timerMult;
    now;
    maxActiveBeacons;
    defaultAutoPauseAfterUnchanged;
    // Escalation (resolved defaults; null when the feature is off).
    esc;
    /** Global in-flight revive count (I9 semaphore). */
    escInFlightGlobal = 0;
    /** Per-tick spawn budget consumed within the current fire() tick window (I9). */
    escSpawnsThisTick = 0;
    escTickAnchorMs = 0;
    /** Double-spawn detection counter (§6) — must stay 0; rollout stop signal. */
    doubleSpawnCount = 0;
    /** AttemptIds already counted as a double-spawn (dedupe per episode, §6). */
    doubleSpawnCountedAttempts = new Set();
    constructor(config) {
        super();
        this.config = config;
        this.prefix = config.prefix ?? '⏳';
        this.minCadenceMs = config.minCadenceMs ?? 60_000;
        this.maxCadenceMs = config.maxCadenceMs ?? 21_600_000;
        this.timerMult = config.__dev_timerMultiplier ?? 1.0;
        this.now = config.now ?? (() => Date.now());
        this.maxActiveBeacons = config.maxActiveBeacons ?? 20;
        this.defaultAutoPauseAfterUnchanged = config.defaultAutoPauseAfterUnchanged ?? 4;
        this.stateDir = path.join(config.stateDir, 'state', 'promise-beacon');
        try {
            fs.mkdirSync(this.stateDir, { recursive: true });
        }
        catch { /* ok */ }
        const e = config.escalation;
        this.esc = (e && e.enabled) ? {
            enabled: true,
            dryRun: e.dryRun ?? true,
            maxEscalationAttempts: e.maxEscalationAttempts ?? 3,
            minEscalationIntervalMs: e.minEscalationIntervalMs ?? 120_000,
            maxConcurrentEscalations: e.maxConcurrentEscalations ?? 2,
            maxEscalationSpawnsPerTick: e.maxEscalationSpawnsPerTick ?? 1,
            reviveSettleMs: e.reviveSettleMs ?? 30_000,
            escalationGraceMs: e.escalationGraceMs ?? 10_000,
            rung2MaxNotifications: e.rung2MaxNotifications ?? 4,
            rung2MinIntervalMs: e.rung2MinIntervalMs ?? 1_800_000,
            rung2DigestWindowMs: e.rung2DigestWindowMs ?? 600_000,
            revalidationTtlMs: e.revalidationTtlMs ?? 1_800_000,
        } : null;
    }
    /** Escalation metrics snapshot (§6). Read-only; surfaced at /commitments/escalation-metrics. */
    escalationMetrics() {
        return {
            enabled: !!this.esc?.enabled,
            dryRun: !!this.esc?.dryRun,
            inFlight: this.escInFlightGlobal,
            doubleSpawnCount: this.doubleSpawnCount,
        };
    }
    start() {
        if (this.started)
            return;
        this.started = true;
        // Re-arm any existing beacon-enabled pending commitments.
        const active = this.config.commitmentTracker
            .getActive()
            .filter(c => c.beaconEnabled && c.status === 'pending' && !c.beaconSuppressed);
        // Boot-cap enforcement (spec Round 3 #2).
        // If the count of beacon-enabled pending commitments exceeds the cap,
        // newest-first is kept; overflow is marked `beaconSuppressed` with reason
        // `boot-cap-exceeded`. Non-terminal — status stays `pending`.
        const sorted = [...active].sort((a, b) => {
            const ta = new Date(a.createdAt).getTime();
            const tb = new Date(b.createdAt).getTime();
            return tb - ta; // newest first
        });
        const keep = sorted.slice(0, this.maxActiveBeacons);
        const overflow = sorted.slice(this.maxActiveBeacons);
        if (overflow.length > 0) {
            console.log(`[PromiseBeacon] Boot-cap exceeded: ${overflow.length} commitment(s) suppressed (cap=${this.maxActiveBeacons})`);
            for (const c of overflow) {
                // Fire-and-forget — the mutate surface handles CAS retries.
                this.config.commitmentTracker
                    .mutate(c.id, prev => ({
                    ...prev,
                    beaconSuppressed: true,
                    beaconSuppressionReason: 'boot-cap-exceeded',
                }))
                    .catch(err => console.warn(`[PromiseBeacon] boot-cap suppress failed for ${c.id}:`, err.message));
            }
            this.emit('boot-cap.exceeded', { cap: this.maxActiveBeacons, suppressed: overflow.map(c => c.id) });
        }
        for (const c of keep) {
            this.schedule(c);
        }
        // React to new beacon-enabled commitments as they're recorded.
        this.config.commitmentTracker.on('recorded', (c) => {
            if (c.beaconEnabled && c.status === 'pending' && c.topicId) {
                this.schedule(c);
            }
        });
        this.config.commitmentTracker.on('delivered', (c) => {
            this.stopFor(c.id);
        });
        this.config.commitmentTracker.on('withdrawn', (c) => {
            this.stopFor(c.id);
        });
        // Re-arm when a paused beacon is resumed via API / Telegram intent.
        this.config.commitmentTracker.on('resumed', (c) => {
            if (c.beaconEnabled && c.status === 'pending' && c.topicId) {
                // Reset the hot-state counter so the resumed run starts fresh.
                const hot = this.loadHotState(c.id);
                hot.consecutiveUnchanged = 0;
                this.saveHotState(c.id, hot);
                this.schedule(c);
            }
        });
        // C1+C2 §4.4 — arm the external-block staleness governor (a slow global
        // sweep, off the per-commitment timer path). No-op each tick when the
        // feature is off; cheap O(active) scan otherwise.
        const sweepMs = this.config.externalBlockSweepMs ?? 60 * 60_000;
        this.externalBlockSweepTimer = setInterval(() => {
            // Lease-gated (§4.5): only the lease-holder runs the mutating sweep.
            if (this.config.holdsLease && !this.config.holdsLease())
                return;
            this.sweepExternalBlocks()
                .then(() => this.maybeReconcileGraveyard())
                .catch(err => console.warn('[PromiseBeacon] external-block sweep error:', err.message));
        }, sweepMs * this.timerMult);
        if (typeof this.externalBlockSweepTimer.unref === 'function')
            this.externalBlockSweepTimer.unref();
        console.log(`[PromiseBeacon] Started (${this.prefix})`);
    }
    stop() {
        if (!this.started)
            return;
        this.started = false;
        for (const t of this.timers.values())
            clearTimeout(t);
        this.timers.clear();
        if (this.externalBlockSweepTimer) {
            clearInterval(this.externalBlockSweepTimer);
            this.externalBlockSweepTimer = undefined;
        }
        console.log('[PromiseBeacon] Stopped');
    }
    /** Public for tests + delivery race: cancel the timer for one commitment. */
    stopFor(id) {
        const t = this.timers.get(id);
        if (t) {
            clearTimeout(t);
            this.timers.delete(id);
        }
    }
    /** Schedule (or re-schedule) the next heartbeat for a commitment. */
    schedule(c) {
        if (!this.started)
            return;
        if (!c.topicId)
            return;
        if (c.status !== 'pending' || c.beaconSuppressed || c.beaconPaused)
            return;
        // atRisk doubles cadence (spec Round 3 #1) — softer-toned + less frequent.
        const baseCadence = c.cadenceMs ?? 20 * 60_000;
        const effective = c.atRisk ? baseCadence * 2 : baseCadence;
        const cadence = this.clampCadence(effective) * this.timerMult;
        const hot = this.loadHotState(c.id);
        const last = hot.lastHeartbeatAt ? new Date(hot.lastHeartbeatAt).getTime() : new Date(c.createdAt).getTime();
        const dueAt = last + cadence;
        const delay = Math.max(1_000, dueAt - this.now());
        const existing = this.timers.get(c.id);
        if (existing)
            clearTimeout(existing);
        const timer = setTimeout(() => {
            this.timers.delete(c.id);
            this.fire(c.id).catch(err => {
                console.error(`[PromiseBeacon] fire() error for ${c.id}:`, err.message);
            });
        }, delay);
        timer.unref?.();
        this.timers.set(c.id, timer);
    }
    /** Fire one heartbeat. Public for testing. */
    async fire(id) {
        const c = this.config.commitmentTracker.getAll().find(x => x.id === id);
        if (!c)
            return;
        if (c.status !== 'pending' || c.beaconSuppressed || c.beaconPaused)
            return;
        if (!c.topicId)
            return;
        // ── Ownership gate ──
        if (this.config.speakerElection && typeof c.topicId === 'number') {
            // WS3 one-voice election: live owner re-resolution at speak time (the
            // stamp is only the fallback, so a backfill racing a transfer cannot
            // wedge the gate), failing toward speech-with-dedup — unknown ownership
            // never silences the pool. Silent and defer verdicts both re-arm.
            const verdict = this.config.speakerElection.decide(c.topicId, c.ownerMachineId ?? null);
            if (!verdict.speak) {
                this.schedule(c);
                return;
            }
        }
        else if (this.config.currentMachineId && c.ownerMachineId && c.ownerMachineId !== this.config.currentMachineId) {
            // Legacy static gate (no election wired): not ours; skip + re-arm.
            this.schedule(c);
            return;
        }
        // ── Quiet hours ──
        if (this.inQuietHours()) {
            await this.suppress(c, 'quiet-hours');
            return;
        }
        // ── Daily spend cap (hard, via LlmQueue) ──
        // LlmQueue enforces this on enqueue; we additionally short-circuit here
        // so we emit beaconSuppressed (non-terminal) rather than fail-open.
        if (this.config.llmQueue.getDailySpendCents() >= (this.config.maxDailyLlmSpendCents ?? 100)) {
            await this.suppress(c, 'daily-spend-cap');
            return;
        }
        // ── Session-epoch check / escalation ──
        const sessionName = this.config.getSessionForTopic(c.topicId);
        const liveEpoch = (sessionName && this.config.getSessionEpoch)
            ? this.config.getSessionEpoch(sessionName)
            : null;
        if (this.esc?.enabled) {
            // (a) Resolve any in-flight revive deterministically (§3.1 timeout contract).
            if (c.escalationInFlight) {
                const verdict = await this.resolveInFlight(c, sessionName, liveEpoch);
                if (verdict !== 'confirmed') {
                    // 'pending' (still settling) or 'failed' (cleared) → re-arm, re-evaluate next tick.
                    this.schedule(c);
                    return;
                }
                // 'confirmed' → epoch re-stamped, fall through to a normal heartbeat below.
            }
            else {
                // (b) Detect session-loss: re-epoched, OR fully gone after having had one.
                const hadEpoch = !!c.sessionEpoch;
                const reEpoched = !!(liveEpoch && c.sessionEpoch && c.sessionEpoch !== liveEpoch);
                const fullyGone = hadEpoch && !sessionName;
                if (reEpoched || fullyGone) {
                    await this.escalate(c);
                    return;
                }
            }
        }
        else {
            // Legacy behavior (escalation off): immediate terminal on epoch mismatch.
            if (sessionName && this.config.getSessionEpoch && c.sessionEpoch && liveEpoch && c.sessionEpoch !== liveEpoch) {
                await this.transitionViolated(c, 'session-lost');
                return;
            }
        }
        // ── Proxy coordinator: one proxy-class message per topic ──
        if (!this.config.proxyCoordinator.tryAcquire(c.topicId, 'promise-beacon')) {
            // PresenceProxy holds — re-arm and bow out.
            this.schedule(c);
            return;
        }
        try {
            // ── Capture (raw frame for turn-state, sanitized for hashing) ──
            const rawFrame = sessionName ? (this.config.captureSessionOutput(sessionName, 200) ?? '') : '';
            const snapshot = rawFrame ? sanitizeTmuxOutput(capOutput(rawFrame), []) : '';
            const hash = snapshot ? sha256(normalizeForHash(snapshot)) : 'empty';
            const hot = this.loadHotState(c.id);
            const unchanged = hash === hot.lastSnapshotHash;
            const nowIso = new Date(this.now()).toISOString();
            const excerpt = promiseExcerpt(c);
            const suffix = excerpt ? ` — re: ${excerpt}` : '';
            // ── B2: turn-finished close-out (HONEST-PROGRESS-MESSAGING / FD-1) ──
            // If the session is alive but its live frame shows no active-work indicator
            // for N consecutive checks, the promised work's turn has wrapped — emit ONE
            // close-out prompt and auto-pause instead of heart-beating into a finished
            // room. Inactive (degrades safely) when looksActivelyWorking isn't wired.
            if (sessionName && this.config.looksActivelyWorking && this.config.isSessionAlive(sessionName) && rawFrame) {
                let working = true;
                try {
                    working = this.config.looksActivelyWorking(rawFrame, sessionName);
                }
                catch {
                    // @silent-fallback-ok: FD-6 fail-safe — an unreliable detector read
                    // defaults to "still working" so a turn-finished close-out is never
                    // FALSELY fired. The safe direction for a notification suppressor.
                    working = true;
                }
                if (!working) {
                    hot.consecutiveTurnFinished = (hot.consecutiveTurnFinished ?? 0) + 1;
                    const closeoutAt = this.config.turnFinishedCloseoutChecks ?? 3;
                    if (hot.consecutiveTurnFinished >= closeoutAt) {
                        hot.lastSnapshotHash = hash;
                        this.saveHotState(c.id, hot);
                        await this.closeOutTurnFinished(c, excerpt);
                        return; // closed out + paused — do NOT re-arm
                    }
                }
                else {
                    hot.consecutiveTurnFinished = 0;
                }
            }
            let text = null;
            let atRiskSignal = false;
            let livenessFired = false;
            if (snapshot && !unchanged) {
                // ── Genuine new output → real LLM-summarized progress line ──
                hot.consecutiveUnchanged = 0;
                try {
                    const line = await this.config.llmQueue.enqueue('background', (signal) => {
                        if (this.config.generateStatusLine) {
                            return this.config.generateStatusLine(c.agentResponse || c.userRequest, snapshot, signal);
                        }
                        // No generator wired → templated.
                        return Promise.resolve('working on it — recent output observed');
                    }, 
                    // Rough Haiku estimate (~3k tokens in/out).
                    1);
                    const guard = guardProxyOutput(line);
                    let safeLine = guard.safe ? line : 'working on it';
                    // ── atRisk classifier (signal-only) ──
                    // If a classifier is wired, ask it whether the snapshot reads as
                    // stalled. This is a *signal*, not authority: the beacon will flag
                    // the commitment atRisk and soften the heartbeat, but NEVER
                    // auto-transition to `violated` from this input. Spec Round 3 #1.
                    if (this.config.classifyProgress) {
                        try {
                            const verdict = await this.config.llmQueue.enqueue('background', (s) => this.config.classifyProgress(c.agentResponse || c.userRequest, snapshot, s), 1);
                            if (verdict === 'stalled') {
                                atRiskSignal = true;
                                const softPhrase = AT_RISK_VARIANTS[hot.templatedVariantCursor % AT_RISK_VARIANTS.length];
                                safeLine = softPhrase;
                            }
                        }
                        catch {
                            // Classifier failure is non-fatal — fall through with original line.
                        }
                    }
                    text = `${this.prefix} ${safeLine}${suffix}`;
                }
                catch (err) {
                    if (err instanceof LlmAbortedError || err.message.includes('cap exceeded') || err.message.includes('reserve')) {
                        text = `${this.prefix} still working (update deferred)${suffix}`;
                    }
                    else {
                        text = `${this.prefix} still working (status fetch failed)${suffix}`;
                    }
                }
            }
            else {
                // ── Unchanged (or no snapshot) ──
                hot.consecutiveUnchanged += 1;
                const unchangedIsAtRisk = hot.consecutiveUnchanged >= 2;
                if (unchangedIsAtRisk)
                    atRiskSignal = true;
                // B1: suppress the "nothing changed" filler by default. Speak only on
                // deadline pressure (B1a) or a sparse liveness tick (B1b). When the
                // operator opts out (suppressUnchangedHeartbeats:false), keep the legacy
                // every-tick templated line (rollback path).
                if (this.config.suppressUnchangedHeartbeats === false) {
                    const variants = unchangedIsAtRisk ? AT_RISK_VARIANTS : TEMPLATED_VARIANTS;
                    const phrase = variants[hot.templatedVariantCursor % variants.length];
                    text = `${this.prefix} ${phrase}${suffix}`;
                    hot.templatedVariantCursor += 1;
                }
                else {
                    const deadlineLine = this.deadlinePressureLine(c, suffix);
                    if (deadlineLine) {
                        text = deadlineLine;
                    }
                    else {
                        const livenessLine = this.maybeLivenessLine(c, hot, suffix, sessionName);
                        if (livenessLine) {
                            text = livenessLine;
                            livenessFired = true;
                        }
                    }
                }
            }
            // ── Send (only if there is something true to say — B1) ──
            const sent = text != null;
            if (sent) {
                await this.emitUserSend(c, text, 'heartbeat');
                if (livenessFired)
                    hot.lastLivenessAt = nowIso;
                hot.heartbeatCount += 1;
            }
            // ── Persist hot state (cadence advances every check, sent or not, so a
            //    suppressed tick can't tight-loop on a stale lastHeartbeatAt). ──
            hot.lastHeartbeatAt = nowIso;
            hot.lastSnapshotHash = hash;
            this.saveHotState(c.id, hot);
            await this.config.commitmentTracker.mutate(c.id, prev => ({
                ...prev,
                lastHeartbeatAt: nowIso,
                // heartbeatCount counts messages actually SENT (B1: a suppressed,
                // unchanged-snapshot check is not a heartbeat the user saw).
                heartbeatCount: (prev.heartbeatCount ?? 0) + (sent ? 1 : 0),
                lastSnapshotHash: hash,
                // atRisk is a signal-driven, non-terminal flag. Setting it does NOT
                // change status; it only nudges tone and doubles cadence below.
                atRisk: atRiskSignal ? true : prev.atRisk,
            }));
            this.emit('heartbeat.fired', {
                id: c.id,
                topicId: c.topicId,
                templated: !snapshot || unchanged,
                atRisk: atRiskSignal,
                sent,
            });
            // ── Auto-pause gate ───────────────────────────────────────────
            // After enough consecutive unchanged-snapshot heartbeats, emit one
            // final "auto-paused" message and stop firing. Non-terminal: status
            // stays `pending`; resume via POST /commitments/:id/resume or a
            // "keep watching" reply on the same topic.
            const threshold = c.beaconAutoPauseAfterUnchanged ?? this.defaultAutoPauseAfterUnchanged;
            const isUnchangedThisCycle = !snapshot || unchanged;
            if (threshold > 0 && isUnchangedThisCycle && hot.consecutiveUnchanged >= threshold) {
                await this.autoPause(c, excerpt);
                return; // do NOT re-arm
            }
        }
        finally {
            this.config.proxyCoordinator.release(c.topicId, 'promise-beacon');
        }
        // Re-arm.
        const next = this.config.commitmentTracker.getAll().find(x => x.id === id);
        if (next)
            this.schedule(next);
    }
    /**
     * Auto-pause a beacon after a run of unchanged heartbeats. Emits one final
     * user-visible line telling them how to resume, mutates the commitment to
     * set `beaconPaused`, and clears the timer. Non-terminal: status stays
     * `pending`. Resume re-arms via the `resumed` event handler.
     */
    async autoPause(c, excerpt) {
        const suffix = excerpt ? ` — re: ${excerpt}` : '';
        const finalText = `${this.prefix} auto-paused after long quiet${suffix}\nReply "keep watching" on this topic to resume.`;
        try {
            await this.emitUserSend(c, finalText, 'closeOut');
        }
        catch (err) {
            console.warn(`[PromiseBeacon] auto-pause send failed for ${c.id}:`, err.message);
        }
        const hot = this.loadHotState(c.id);
        await this.config.commitmentTracker.mutate(c.id, prev => ({
            ...prev,
            beaconPaused: true,
            beaconPausedReason: 'auto-paused-no-progress',
            beaconPausedAt: new Date(this.now()).toISOString(),
            // Snapshot the streak length that triggered the pause — written ONLY
            // at the pause boundary, not on every heartbeat (review feedback).
            consecutiveUnchanged: hot.consecutiveUnchanged,
        }));
        this.stopFor(c.id);
        this.emit('auto-paused', { id: c.id, topicId: c.topicId });
    }
    /**
     * B1a (HONEST-PROGRESS-MESSAGING) — deadline-pressure exception. Total silence
     * near a hard deadline is itself dishonest. When the commitment's hard deadline
     * is within 2× the effective cadence, an unchanged tick still speaks with a
     * low-confidence, honest line. Returns null when there is no deadline pressure.
     */
    deadlinePressureLine(c, suffix) {
        if (!c.hardDeadlineAt)
            return null;
        const deadline = Date.parse(c.hardDeadlineAt);
        if (Number.isNaN(deadline))
            return null;
        const remaining = deadline - this.now();
        if (remaining <= 0)
            return null; // past the deadline — expiry is handled elsewhere
        const baseCadence = c.cadenceMs ?? 20 * 60_000;
        const effective = c.atRisk ? baseCadence * 2 : baseCadence;
        if (remaining > 2 * effective)
            return null;
        return `${this.prefix} no visible new output${suffix}, but still within your deadline (${humanizeMs(remaining)} left) — watching closely.`;
    }
    /**
     * B1b (HONEST-PROGRESS-MESSAGING) — sparse liveness. When unchanged heartbeats
     * are suppressed, a genuinely long task would go fully dark. Emit at most one
     * "still watching, N min in" line per beaconLivenessIntervalMs while a session
     * is present. Returns null when a liveness line isn't due yet.
     */
    maybeLivenessLine(c, hot, suffix, sessionName) {
        if (!sessionName)
            return null;
        const interval = this.config.beaconLivenessIntervalMs ?? 60 * 60_000;
        const anchor = hot.lastLivenessAt
            ? Date.parse(hot.lastLivenessAt)
            : Date.parse(c.createdAt);
        if (Number.isNaN(anchor))
            return null;
        if (this.now() - anchor < interval)
            return null;
        const createdAt = Date.parse(c.createdAt);
        const minIn = Number.isNaN(createdAt)
            ? 0
            : Math.max(1, Math.round((this.now() - createdAt) / 60_000));
        return `${this.prefix} still watching${suffix} — ${minIn} min in, no new output yet.`;
    }
    /**
     * B2 (HONEST-PROGRESS-MESSAGING) — turn-finished close-out. The promise's
     * session has wrapped its turn; emit ONE honest close-out prompt and auto-pause
     * (no clockwork heartbeats into a finished room). Non-terminal: status stays
     * `pending`; resume re-arms via the `resumed` handler.
     */
    async closeOutTurnFinished(c, excerpt) {
        const re = excerpt || 'this task';
        const finalText = `${this.prefix} I said I'd follow up on "${re}" but that work's session has wrapped — ` +
            `want me to pick it back up, or close this out?`;
        try {
            await this.emitUserSend(c, finalText, 'closeOut');
        }
        catch (err) {
            console.warn(`[PromiseBeacon] turn-finished close-out send failed for ${c.id}:`, err.message);
        }
        await this.config.commitmentTracker.mutate(c.id, prev => ({
            ...prev,
            beaconPaused: true,
            beaconPausedReason: 'turn-finished',
            beaconPausedAt: new Date(this.now()).toISOString(),
        }));
        this.stopFor(c.id);
        this.emit('auto-paused', { id: c.id, topicId: c.topicId, reason: 'turn-finished' });
    }
    // ─── Internals ────────────────────────────────────────────────────────────
    async suppress(c, reason) {
        await this.config.commitmentTracker.mutate(c.id, prev => ({
            ...prev,
            beaconSuppressed: true,
            beaconSuppressionReason: reason,
        }));
        this.emit('heartbeat.skipped', { id: c.id, reason });
        // Re-arm for re-evaluation: 10min window for quiet-hours, 1h for cap.
        const reArmDelay = reason === 'quiet-hours' ? 10 * 60_000 : 60 * 60_000;
        const timer = setTimeout(() => {
            this.timers.delete(c.id);
            // Clear suppression flag on re-evaluation attempt.
            this.config.commitmentTracker
                .mutate(c.id, prev => ({ ...prev, beaconSuppressed: false, beaconSuppressionReason: undefined }))
                .then(updated => this.schedule(updated))
                .catch(() => { });
        }, reArmDelay * this.timerMult);
        timer.unref?.();
        this.timers.set(c.id, timer);
    }
    // ─── Escalation ladder (PROMISE-BEACON-ESCALATION-SPEC §3) ──────────────────
    /** Exponential backoff floor between Rung-1 attempts (I1). */
    escBackoffMs(attempt) {
        const min = this.esc.minEscalationIntervalMs;
        return Math.max(min, Math.pow(2, Math.max(0, attempt - 1)) * min);
    }
    /** Reset the per-tick spawn budget when a new ~tick window opens (I9). */
    rollEscTick() {
        const now = this.now();
        if (now - this.escTickAnchorMs > 5_000) {
            this.escTickAnchorMs = now;
            this.escSpawnsThisTick = 0;
        }
    }
    /** Emit one escalation-audit event (§6). Also covers dry-run "would" logging. */
    auditEsc(c, decision, extra = {}) {
        this.emit('escalation', {
            id: c.id,
            topicId: c.topicId,
            decision,
            dryRun: !!this.esc?.dryRun,
            attempts: c.escalationAttempts ?? 0,
            ...extra,
        });
    }
    /**
     * Rung 1 — attempt to revive the owning session, with all the §4 brakes.
     * Decides between Rung 1 (spawn), Rung 2 (honest status), and Rung 3 (give up).
     */
    async escalate(c) {
        const esc = this.esc;
        const attempts = c.escalationAttempts ?? 0;
        // Cap (I1): out of attempts → Rung 3 loud give-up.
        if (attempts >= esc.maxEscalationAttempts) {
            await this.rung3(c);
            return;
        }
        // Backoff floor (I1): not yet time for the next attempt → re-arm, no-op.
        const lastAt = c.lastEscalationAt ? Date.parse(c.lastEscalationAt) : 0;
        if (lastAt && this.now() - lastAt < this.escBackoffMs(attempts)) {
            this.auditEsc(c, 'backoff-hold');
            return; // caller re-arms
        }
        // Ownership re-check immediately before spawn (I4) — standby never spawns.
        if (this.config.speakerElection && typeof c.topicId === 'number') {
            const verdict = this.config.speakerElection.decide(c.topicId, c.ownerMachineId ?? null);
            if (!verdict.speak) {
                this.auditEsc(c, 'not-owner');
                return;
            }
        }
        // Global budget (I9): concurrency + per-tick spawn cap → fall to Rung 2.
        this.rollEscTick();
        if (this.escInFlightGlobal >= esc.maxConcurrentEscalations ||
            this.escSpawnsThisTick >= esc.maxEscalationSpawnsPerTick) {
            await this.rung2(c, 'quota-limited');
            return;
        }
        // Dry-run: log the intended Rung-1 spawn; take no real action.
        if (esc.dryRun) {
            this.auditEsc(c, 'would-revive', { rung: 1 });
            return;
        }
        if (!this.config.requestRevive) {
            // No spawn path wired → honest status instead.
            await this.rung2(c, 'owner-gone');
            return;
        }
        // Rung 1 commit: increment BEFORE the spawn (I1), set in-flight + idempotency
        // key + revivalMode, all via one CAS mutate (server-written-only, I11).
        const attemptId = crypto.randomUUID();
        const nowIso = new Date(this.now()).toISOString();
        await this.config.commitmentTracker.mutate(c.id, prev => ({
            ...prev,
            escalationAttempts: (prev.escalationAttempts ?? 0) + 1,
            lastEscalationAt: nowIso,
            escalationAttemptId: attemptId,
            escalationInFlight: true,
            revivalMode: 'status-only-until-revalidated',
            currentRung: '1',
        }));
        this.escInFlightGlobal += 1;
        this.escSpawnsThisTick += 1;
        this.auditEsc(c, 'revive-requested', { rung: 1, attemptId });
        let result;
        try {
            result = await this.config.requestRevive({
                topicId: c.topicId,
                commitmentId: c.id,
                escalationAttemptId: attemptId,
                userRequest: c.userRequest,
                agentResponse: c.agentResponse,
            });
        }
        catch {
            result = { sessionName: null, refusalReason: 'quota' };
        }
        if (!result.sessionName) {
            // Spawn refused — release the in-flight marker now (the attempt already
            // counted) and fall to Rung 2 with the state-specific recoverability.
            this.escInFlightGlobal = Math.max(0, this.escInFlightGlobal - 1);
            await this.config.commitmentTracker.mutate(c.id, prev => ({
                ...prev,
                escalationInFlight: false,
                refusalReason: result.refusalReason,
            }));
            await this.rung2(c, this.refusalToState(result.refusalReason));
            return;
        }
        // Success: in-flight stays set; resolveInFlight() confirms it on a later
        // tick via the §3.1 timeout contract. Caller re-arms.
        this.auditEsc(c, 'revive-spawned', { rung: 1, sessionName: result.sessionName });
    }
    /** Map a spawn refusal to its Rung-2 recoverability state (§3.2). */
    refusalToState(r) {
        switch (r) {
            case 'quota': return 'quota-limited';
            case 'budget': return 'quota-limited';
            case 'resume-queue-owns': return 'retryable';
            case 'lease': return 'owner-gone';
            case 'unbound': return 'owner-gone';
            default: return 'owner-gone';
        }
    }
    /**
     * §3.1 deterministic in-flight resolution. Returns 'confirmed' (epoch
     * re-stamped, fall through to heartbeat), 'failed' (cleared, re-evaluate),
     * or 'pending' (still within the settle window).
     */
    async resolveInFlight(c, sessionName, liveEpoch) {
        const esc = this.esc;
        const lastAt = c.lastEscalationAt ? Date.parse(c.lastEscalationAt) : 0;
        const age = this.now() - lastAt;
        // ── Double-spawn detection (§6) ──
        // Two distinct live sessions bound to one topic while this commitment is
        // escalationInFlight is the partition/race double-spawn signature. Counted
        // ONCE per attempt (deduped) and surfaced at /commitments/escalation-metrics
        // — any non-zero value is the rollout hard-stop signal. The reaper's
        // single-session-per-topic closeout is the auto-correction (§9); this is the
        // detection arm.
        if (this.config.liveSessionCountForTopic && typeof c.topicId === 'number' && c.escalationAttemptId) {
            try {
                if (this.config.liveSessionCountForTopic(c.topicId) > 1 && !this.doubleSpawnCountedAttempts.has(c.escalationAttemptId)) {
                    this.doubleSpawnCountedAttempts.add(c.escalationAttemptId);
                    this.recordDoubleSpawn();
                    this.auditEsc(c, 'double-spawn-detected', { attemptId: c.escalationAttemptId });
                }
            }
            catch { /* detection is best-effort; never throws into the tick */ }
        }
        // Confirmed: a live session with a NEW epoch, settled ≥ reviveSettleMs.
        const isNewLive = !!(sessionName && liveEpoch && liveEpoch !== c.sessionEpoch);
        if (isNewLive && age >= esc.reviveSettleMs) {
            this.escInFlightGlobal = Math.max(0, this.escInFlightGlobal - 1);
            if (c.escalationAttemptId)
                this.doubleSpawnCountedAttempts.delete(c.escalationAttemptId);
            await this.config.commitmentTracker.mutate(c.id, prev => ({
                ...prev,
                sessionEpoch: liveEpoch,
                escalationInFlight: false,
                escalationAttemptId: undefined,
                currentRung: null,
                refusalReason: undefined,
            }));
            this.auditEsc(c, 'revive-confirmed');
            this.emit('escalation.revived', { id: c.id, topicId: c.topicId });
            return 'confirmed';
        }
        // Failed: past the settle+grace window with no confirmed session.
        if (age > esc.reviveSettleMs + esc.escalationGraceMs) {
            this.escInFlightGlobal = Math.max(0, this.escInFlightGlobal - 1);
            if (c.escalationAttemptId)
                this.doubleSpawnCountedAttempts.delete(c.escalationAttemptId);
            await this.config.commitmentTracker.mutate(c.id, prev => ({
                ...prev,
                escalationInFlight: false,
                escalationAttemptId: undefined,
            }));
            this.auditEsc(c, 'revive-failed');
            return 'failed';
        }
        return 'pending';
    }
    /** Rung 2 — honest, state-specific interim status, bounded (§3.2 + I12). */
    async rung2(c, state) {
        const esc = this.esc;
        const sent = c.rung2NotificationCount ?? 0;
        // Bounded: exhausted Rung-2 budget → Rung 3 (operator-needed).
        if (sent >= esc.rung2MaxNotifications) {
            await this.rung3(c);
            return;
        }
        // Per-commitment floor.
        const lastRung2 = c.lastRung2At ? Date.parse(c.lastRung2At) : 0;
        if (lastRung2 && this.now() - lastRung2 < esc.rung2MinIntervalMs) {
            this.auditEsc(c, 'rung2-floor-hold', { state });
            return;
        }
        // Quiet-hours re-gate (I7).
        if (this.inQuietHours()) {
            this.auditEsc(c, 'rung2-quiet-hours', { state });
            return;
        }
        if (esc.dryRun) {
            this.auditEsc(c, 'would-rung2', { state });
            return;
        }
        const excerpt = redactSecrets(promiseExcerpt(c));
        const text = rung2Message(state, excerpt);
        if (text && c.topicId) {
            try {
                await this.emitUserSend(c, text, 'rung2');
            }
            catch (err) {
                console.warn(`[PromiseBeacon] rung2 send failed for ${c.id}:`, err.message);
            }
        }
        await this.config.commitmentTracker.mutate(c.id, prev => ({
            ...prev,
            rung2NotificationCount: (prev.rung2NotificationCount ?? 0) + 1,
            lastRung2At: new Date(this.now()).toISOString(),
            currentRung: '2',
            atRisk: true,
        }));
        this.auditEsc(c, 'rung2-sent', { state });
    }
    /** Rung 3 — bounded, loud give-up: terminal + ONE deduped Attention item (§3.3). */
    async rung3(c) {
        if (this.esc?.dryRun) {
            this.auditEsc(c, 'would-give-up', { rung: 3 });
            return;
        }
        await this.config.commitmentTracker.mutate(c.id, prev => ({
            ...prev,
            status: 'violated',
            resolvedAt: new Date(this.now()).toISOString(),
            resolution: 'session-lost-unrecovered',
            currentRung: '3',
            escalationInFlight: false,
        }));
        const detail = `Promise "${(c.agentResponse || c.userRequest).slice(0, 80)}" could not be revived after ${c.escalationAttempts ?? 0} attempts.`;
        try {
            this.config.raiseAttention?.(c.id, detail);
        }
        catch { /* non-fatal */ }
        this.stopFor(c.id);
        this.auditEsc(c, 'gave-up', { rung: 3 });
        this.emit('promise.violated', { id: c.id, reason: 'session-lost-unrecovered' });
    }
    /** Record a confirmed double-spawn (§6) — invoked by the per-topic reconciliation. */
    recordDoubleSpawn() { this.doubleSpawnCount += 1; }
    /**
     * C1+C2 owner-gated outbound chokepoint (spec agent-owned-followthrough §4.2).
     * EVERY beacon user-send routes through here. Beacon sends are `isProxy:true`
     * and bypass MessagingToneGate, so the owner-gate MUST live here, not at the
     * gate. Rollout-gated (§4.8): when the feature is off (fleet default) this is a
     * strict no-op — sends go out exactly as before. When on+dryRun it logs the
     * intended action but still sends (observe-first). When on+live and the
     * commitment is owner:'agent', status kinds are suppressed (the agent carries
     * the loop — the user is never status-messaged) while a `terminal` kind is
     * NEVER suppressed: it reroutes to the Attention dead-letter (raiseAttention,
     * the one always-surfaced channel) so a failure is never swallowed (C2 /
     * "never nag ≠ swallow a failure"). owner:'user' always sends normally.
     */
    async emitUserSend(c, text, kind) {
        const sendNormally = async () => {
            if (c.topicId == null)
                return;
            // Beacon-local B-IDLEAK pass (C1+C2 §4.3): beacon sends are isProxy:true and
            // bypass MessagingToneGate, so B20 can't run on them. Signal-only
            // observability — emit when beacon text leaks internal plumbing (never
            // blocks/scrubs; secret/path redaction stays with guardProxyOutput).
            try {
                const leak = detectInternalIdLeak(text);
                if (leak.leaked)
                    this.emit('aoft.beacon-id-leak', { id: c.id, terms: leak.terms });
            }
            catch { /* non-fatal */ }
            await this.config.sendMessage(c.topicId, text, {
                source: 'promise-beacon',
                isProxy: true,
                tier: 1,
            });
        };
        const state = this.config.agentOwnedFollowthrough?.() ?? { enabled: false, dryRun: true };
        // Feature off, or not an agent-owned commitment → unchanged behavior.
        if (!state.enabled || c.owner !== 'agent') {
            await sendNormally();
            return;
        }
        if (kind === 'terminal') {
            // Terminal failure: reroute to the Attention dead-letter (never status,
            // never swallowed). In dryRun, log the intent but preserve current behavior.
            if (state.dryRun) {
                this.emit('aoft.would-reroute-terminal', { id: c.id, topicId: c.topicId, kind });
                await sendNormally();
                return;
            }
            const detail = text.replace(/^⚠️\s*\[?promise-beacon\]?\s*/i, '').trim() || text;
            try {
                this.config.raiseAttention?.(c.id, detail);
            }
            catch { /* non-fatal */ }
            this.emit('aoft.terminal-rerouted', { id: c.id, topicId: c.topicId, kind });
            return;
        }
        // Status kind under owner:'agent'.
        if (state.dryRun) {
            this.emit('aoft.would-suppress', { id: c.id, topicId: c.topicId, kind });
            await sendNormally();
            return;
        }
        this.emit('aoft.suppressed', { id: c.id, topicId: c.topicId, kind });
    }
    /**
     * C1+C2 §4.4 — the external-block staleness governor. A slow global sweep over
     * owner:'agent', blockedOn:'external' pending commitments. The WINDOW
     * dead-letter is the hard guarantee: when no dependency-probe has landed within
     * the staleness window — OR the wait is past the absolute ceiling regardless of
     * probes — it raises ONE deduped operator Attention item (raiseAttention),
     * NEVER auto-closing (CMT-1101 scar). Deduped via externalBlockDeadLetteredAt
     * (a fresh probe re-arms it). Rollout-gated: off → no-op; dryRun → logs the
     * would-be dead-letter but does not raise it. Public for tests.
     */
    async sweepExternalBlocks() {
        const state = this.config.agentOwnedFollowthrough?.() ?? { enabled: false, dryRun: true };
        if (!state.enabled)
            return;
        const windowMs = this.config.externalBlockWindowMs ?? 24 * 60 * 60_000;
        const ceilingMs = this.config.externalBlockCeilingMs ?? 14 * 24 * 60 * 60_000;
        const now = this.now();
        for (const c of this.config.commitmentTracker.getActive()) {
            if (c.owner !== 'agent' || c.blockedOn !== 'external' || c.status !== 'pending')
                continue;
            if (c.externalBlockDeadLetteredAt)
                continue; // already surfaced this episode
            const createdMs = Date.parse(c.createdAt);
            const lastTouchMs = c.lastProbe?.at ? Date.parse(c.lastProbe.at) : createdMs;
            const windowStale = Number.isFinite(lastTouchMs) && now - lastTouchMs > windowMs;
            const ceilingHit = Number.isFinite(createdMs) && now - createdMs > ceilingMs;
            if (!windowStale && !ceilingHit)
                continue;
            const reason = ceilingHit ? 'absolute-ceiling' : 'no-probe-within-window';
            if (state.dryRun) {
                this.emit('aoft.would-deadletter-external', { id: c.id, reason });
                continue;
            }
            const waited = Number.isFinite(createdMs) ? humanizeMs(now - createdMs) : 'a while';
            const detail = `I've been waiting on an external dependency for "${(c.agentResponse || c.userRequest).slice(0, 80)}" ` +
                `for ${waited} (${reason === 'absolute-ceiling' ? 'past the max wait' : 'no movement in a while'}) — ` +
                `want me to keep waiting or drop it?`;
            try {
                this.config.raiseAttention?.(c.id, detail);
            }
            catch { /* non-fatal */ }
            await this.config.commitmentTracker.mutate(c.id, prev => ({
                ...prev,
                externalBlockDeadLetteredAt: new Date(now).toISOString(),
            }));
            this.emit('aoft.deadlettered-external', { id: c.id, reason });
        }
    }
    /**
     * C1+C2 §4.5 — drive the evidence-gated graveyard reconciler on the slow sweep
     * cadence (lease-gated by the caller). Rollout-gated: off → no-op; passes the
     * feature's dryRun through so the dark→live promotion is evidence-gated.
     */
    maybeReconcileGraveyard() {
        const state = this.config.agentOwnedFollowthrough?.() ?? { enabled: false, dryRun: true };
        if (!state.enabled)
            return;
        try {
            const r = this.config.commitmentTracker.reconcileGraveyard({ dryRun: state.dryRun });
            if (r.closed.length || r.wouldClose.length) {
                this.emit('aoft.graveyard-reconciled', {
                    closed: r.closed.length,
                    wouldClose: r.wouldClose.length,
                    dryRun: state.dryRun,
                });
            }
        }
        catch (err) {
            console.warn('[PromiseBeacon] graveyard reconcile error:', err.message);
        }
    }
    async transitionViolated(c, reason) {
        await this.config.commitmentTracker.mutate(c.id, prev => ({
            ...prev,
            status: 'violated',
            resolvedAt: new Date().toISOString(),
            resolution: reason,
        }));
        // Route through the owner-gated chokepoint as a TERMINAL kind: under
        // owner:'agent' (live) this reroutes to the Attention dead-letter instead of
        // a topic status message (never swallowed, never C2-violating status); off /
        // owner:'user' it sends to the topic exactly as before (guarded on topicId
        // inside emitUserSend).
        await this.emitUserSend(c, `⚠️ [promise-beacon] commitment "${(c.agentResponse || c.userRequest).slice(0, 80)}" violated: ${reason}`, 'terminal');
        this.stopFor(c.id);
        this.emit('promise.violated', { id: c.id, reason });
    }
    inQuietHours() {
        const qh = this.config.quietHours;
        if (!qh)
            return false;
        const [sH, sM] = (qh.start || '22:00').split(':').map(Number);
        const [eH, eM] = (qh.end || '08:00').split(':').map(Number);
        const d = new Date(this.now());
        const mins = d.getHours() * 60 + d.getMinutes();
        const startMin = sH * 60 + sM;
        const endMin = eH * 60 + eM;
        if (startMin < endMin) {
            return mins >= startMin && mins < endMin;
        }
        // Wraps midnight.
        return mins >= startMin || mins < endMin;
    }
    clampCadence(ms) {
        return Math.min(this.maxCadenceMs, Math.max(this.minCadenceMs, ms));
    }
    loadHotState(id) {
        const p = path.join(this.stateDir, `${id}.json`);
        try {
            const raw = fs.readFileSync(p, 'utf-8');
            const parsed = JSON.parse(raw);
            return {
                heartbeatCount: 0,
                consecutiveUnchanged: 0,
                templatedVariantCursor: 0,
                ...parsed,
            };
        }
        catch {
            return {
                heartbeatCount: 0,
                consecutiveUnchanged: 0,
                templatedVariantCursor: 0,
            };
        }
    }
    saveHotState(id, hot) {
        const p = path.join(this.stateDir, `${id}.json`);
        try {
            fs.writeFileSync(p, JSON.stringify(hot, null, 2));
        }
        catch (err) {
            console.error(`[PromiseBeacon] persist failed for ${id}:`, err.message);
        }
    }
    /** Test accessor. */
    getScheduledIds() {
        return [...this.timers.keys()];
    }
}
//# sourceMappingURL=PromiseBeacon.js.map