// GrowthMilestoneAnalyst — the proactive "growth & milestone" analyst.
//
// WHY THIS EXISTS (grounded requirement, Justin 2026-06-06, topic 21624):
// Instar built excellent *sensors* (InitiativeTracker, FeatureRolloutReconciler,
// ApprovalLedger, CorrectionLedger) and excellent *anti-flood plumbing* — but
// never the *analyst layer* in between that reads the tracked data, decides what
// crosses from noise into "a concrete milestone or realization worth telling the
// operator," and proactively surfaces it. The result was total silence on the
// three questions the operator actually asked:
//   1. Are initiatives being left behind?
//   2. Are features earning their way through the maturity path (dark → enabled)?
//   3. Are patterns being extracted from conversation data (approve-vs-change-spec
//      rate, correction rate)?
//
// THE KEY DESIGN LEVER (Justin, same conversation): the incubation/maturation
// window must stay TIGHT — a week MAX, a few days for low-risk features — and
// **the window EXPIRING is the trigger itself**. "Left behind" becomes
// structurally impossible because every incubating feature carries a deadline
// that drags it in front of the operator: either it proved itself (→ promote?)
// or it never did (→ extend / fix / kill?).
//
// MATURITY HONESTY (ref keystone-dormancy #905): promotion requires real
// proof-of-life (the feature actually fired), NOT merely elapsed time. A feature
// that sat dark and never ran is a *kill/fix* candidate, never a *promote* one.
//
// THIS COMPONENT ADDS NO NEW SENSORS. It composes the existing read surfaces and
// keeps ONE piece of minimal internal bookkeeping: a stage-observation journal,
// because the rollout engines do not cleanly stamp "entered stage X at time T"
// for every stage (notably `dark`). The journal records the first time we
// observed a feature in its current stage so "days in stage" is robust.
//
// Spec: docs/specs/PROACTIVE-GROWTH-MILESTONE-ANALYST-SPEC.md
// Ships DARK (monitoring.growthAnalyst.enabled defaults false). This slice
// COMPUTES findings + exposes them via read routes; it does NOT send to Telegram.
// Sending / cadence / enabling the muted analyzers ride later slices, behind
// their own review (they are the flood-sensitive part).
import fs from 'node:fs';
import path from 'node:path';
import { DEV_GATED_FEATURES, getConfigByPath } from '../core/devGatedFeatures.js';
import { resolveDevAgentGate } from '../core/devAgentGate.js';
export const DEFAULT_GROWTH_WINDOWS = {
    lowRisk: 3,
    standard: 7,
    highRisk: 7,
};
export const DEFAULT_GROWTH_SETTINGS = {
    enabled: false,
    incubationWindows: { ...DEFAULT_GROWTH_WINDOWS },
    proofOfLifeMinActivations: 1,
    rules: {
        promotionReady: true,
        incubationExpired: true,
        initiativeStalling: true,
        specPattern: true,
        correctionPattern: true,
        devGateConformance: true,
    },
    specPatternMinTotal: 3,
    specPatternMinChangeRatio: 0.6,
    correctionPatternMinOccurrences: 3,
    digestEvenWhenCalm: true,
};
/** Resolve raw config (possibly partial / undefined) into fully-defaulted
 *  settings. Pure — safe to unit test, and used by both the class and routes. */
export function resolveGrowthSettings(raw) {
    const r = (raw ?? {});
    const windows = (r.incubationWindows ?? {});
    const rules = (r.rules ?? {});
    return {
        enabled: r.enabled === true,
        incubationWindows: {
            lowRisk: numOr(windows.lowRisk, DEFAULT_GROWTH_WINDOWS.lowRisk),
            standard: numOr(windows.standard, DEFAULT_GROWTH_WINDOWS.standard),
            highRisk: numOr(windows.highRisk, DEFAULT_GROWTH_WINDOWS.highRisk),
        },
        proofOfLifeMinActivations: numOr(r.proofOfLifeMinActivations, DEFAULT_GROWTH_SETTINGS.proofOfLifeMinActivations),
        rules: {
            promotionReady: rules.promotionReady !== false,
            incubationExpired: rules.incubationExpired !== false,
            initiativeStalling: rules.initiativeStalling !== false,
            specPattern: rules.specPattern !== false,
            correctionPattern: rules.correctionPattern !== false,
            devGateConformance: rules.devGateConformance !== false,
        },
        specPatternMinTotal: numOr(r.specPatternMinTotal, DEFAULT_GROWTH_SETTINGS.specPatternMinTotal),
        specPatternMinChangeRatio: numOr(r.specPatternMinChangeRatio, DEFAULT_GROWTH_SETTINGS.specPatternMinChangeRatio),
        correctionPatternMinOccurrences: numOr(r.correctionPatternMinOccurrences, DEFAULT_GROWTH_SETTINGS.correctionPatternMinOccurrences),
        digestEvenWhenCalm: r.digestEvenWhenCalm !== false,
    };
}
function numOr(v, fallback) {
    return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
}
/** Whole calendar-ish days between an ISO timestamp and `now` (fractional). */
export function daysSince(iso, now) {
    const then = new Date(iso).getTime();
    if (!Number.isFinite(then))
        return 0;
    return (now.getTime() - then) / 86_400_000;
}
/** Map a feature to a risk tier. Heuristic for now (overridable later via an
 *  explicit per-feature tag). default-on/live features that already shipped are
 *  treated 'standard'; everything dark is 'standard' unless flagged lowRisk. */
export function riskTierForInitiative(init) {
    // An explicit tag on the rollout's promotionCriteria wins if present.
    const crit = init.rollout?.promotionCriteria?.toLowerCase() ?? '';
    if (crit.includes('low-risk') || crit.includes('lowrisk'))
        return 'lowRisk';
    if (crit.includes('high-risk') || crit.includes('highrisk'))
        return 'highRisk';
    return 'standard';
}
/** The core verdict. PURE. `proofCount === undefined` ⇒ no evidence source wired
 *  ⇒ proved:'unknown' ⇒ cannot be promotion-ready (honest: never promote a
 *  feature we can't prove ran). */
export function classifyRollout(stage, daysInStage, windowDays, proofCount, minProof) {
    const proved = proofCount === undefined ? 'unknown' : proofCount >= minProof;
    if (stage === 'default-on') {
        return { classification: 'terminal', stage, daysInStage, windowDays, proved };
    }
    const expired = daysInStage >= windowDays;
    if (!expired) {
        return { classification: 'incubating', stage, daysInStage, windowDays, proved };
    }
    if (proved === true) {
        return { classification: 'promotion-ready', stage, daysInStage, windowDays, proved };
    }
    return { classification: 'expired-unproven', stage, daysInStage, windowDays, proved };
}
export class GrowthMilestoneAnalyst {
    stateDir;
    journalPath;
    deps;
    settings;
    constructor(deps) {
        this.deps = deps;
        this.settings = deps.settings;
        this.stateDir = path.join(deps.stateDir, 'state', 'growth-milestone-analyst');
        this.journalPath = path.join(this.stateDir, 'stage-journal.json');
        try {
            fs.mkdirSync(this.stateDir, { recursive: true });
        }
        catch (err) {
            this.deps.onError?.('mkdir', err);
        }
    }
    now() {
        return this.deps.now ? this.deps.now() : new Date();
    }
    isEnabled() {
        return this.settings.enabled === true;
    }
    // ── Stage journal persistence ──────────────────────────────────────────
    loadJournal() {
        try {
            const raw = fs.readFileSync(this.journalPath, 'utf-8');
            const parsed = JSON.parse(raw);
            return parsed && typeof parsed === 'object' ? parsed : {};
        }
        catch {
            // @silent-fallback-ok: a missing/corrupt journal → empty, then self-heals
            // on the next observeStages write. Observe-only analyst — never gates an
            // action, so an empty journal only means "recompute stage clocks", not a
            // wrong decision. (No DegradationReporter: this is expected first-run state.)
            return {};
        }
    }
    saveJournal(j) {
        try {
            fs.writeFileSync(this.journalPath, JSON.stringify(j, null, 2));
        }
        catch (err) {
            this.deps.onError?.('saveJournal', err);
        }
    }
    /**
     * Observe the current rollout stage of every staged feature and reconcile the
     * journal: new feature → record; stage changed → reset firstObservedAt; gone →
     * prune. Returns the updated journal (also persisted). PUBLIC so a tick can run
     * the observation step independently of finding computation.
     */
    observeStages(now = this.now()) {
        const journal = this.loadJournal();
        const live = new Set();
        for (const init of this.stagedInitiatives()) {
            const stage = init.rollout.stage;
            live.add(init.id);
            const prev = journal[init.id];
            if (!prev || prev.stage !== stage) {
                journal[init.id] = { stage, firstObservedAt: now.toISOString() };
            }
        }
        // Prune features no longer staged (deleted / archived away from rollout).
        for (const id of Object.keys(journal)) {
            if (!live.has(id))
                delete journal[id];
        }
        this.saveJournal(journal);
        return journal;
    }
    stagedInitiatives() {
        let list;
        try {
            list = this.deps.tracker.list();
        }
        catch (err) {
            // @silent-fallback-ok: input-read failure surfaced via onError; returning
            // [] yields a smaller digest (the safe direction) for this observe-only,
            // never-acting component — it can never cause a wrong action.
            this.deps.onError?.('tracker.list', err);
            return [];
        }
        return list.filter((i) => i.rollout && i.rollout.stage);
    }
    // ── Notify-rule computation ─────────────────────────────────────────────
    /** R1 + R2: feature maturity via the tight incubation window. */
    computeRolloutFindings(now, journal) {
        const findings = [];
        const s = this.settings;
        for (const init of this.stagedInitiatives()) {
            const obs = journal[init.id];
            if (!obs)
                continue; // observeStages must run first
            const tier = riskTierForInitiative(init);
            const windowDays = s.incubationWindows[tier];
            const daysInStage = daysSince(obs.firstObservedAt, now);
            let proofCount;
            try {
                proofCount = this.deps.evidenceCounter?.(init);
            }
            catch (err) {
                // @silent-fallback-ok: surfaced via onError; undefined → proved:'unknown',
                // the HONEST conservative result (a feature we can't prove ran can never
                // be promotion-ready). Never a wrong action — observe-only.
                this.deps.onError?.('evidenceCounter', err);
                proofCount = undefined;
            }
            const v = classifyRollout(init.rollout.stage, daysInStage, windowDays, proofCount, s.proofOfLifeMinActivations);
            const rounded = Math.round(daysInStage * 10) / 10;
            if (v.classification === 'promotion-ready' && s.rules.promotionReady) {
                findings.push({
                    rule: 'R1',
                    priority: 'normal',
                    subjectId: init.id,
                    title: `${init.title} — ready to promote`,
                    detail: `Incubated ${rounded}d in '${v.stage}' (window ${windowDays}d) with ${proofCount} real activation(s) and no issues. Promote to the next stage?`,
                    suggestedAction: 'promote',
                    stage: v.stage,
                    daysInStage: rounded,
                    windowDays,
                    proved: v.proved,
                });
            }
            else if (v.classification === 'expired-unproven' && s.rules.incubationExpired) {
                const provedNote = v.proved === 'unknown'
                    ? 'no evidence source is wired, so it cannot prove it ran'
                    : `it has not reached ${s.proofOfLifeMinActivations} activation(s)`;
                findings.push({
                    rule: 'R2',
                    priority: 'normal',
                    subjectId: init.id,
                    title: `${init.title} — incubation expired, unproven`,
                    detail: `Sat in '${v.stage}' ${rounded}d (window ${windowDays}d) but ${provedNote}. Extend, fix, or kill?`,
                    suggestedAction: 'extend-fix-kill',
                    stage: v.stage,
                    daysInStage: rounded,
                    windowDays,
                    proved: v.proved,
                });
            }
        }
        return findings;
    }
    /** R3: initiatives left behind — reuse the tracker's own staleness digest. */
    computeStallingFindings(now) {
        if (!this.settings.rules.initiativeStalling)
            return [];
        let items;
        try {
            items = this.deps.tracker.digest(now).items;
        }
        catch (err) {
            // @silent-fallback-ok: input-read failure surfaced via onError; [] yields a
            // smaller digest (safe direction) for this observe-only component.
            this.deps.onError?.('tracker.digest', err);
            return [];
        }
        const findings = [];
        for (const it of items) {
            if (it.reason !== 'stale' && it.reason !== 'needs-user')
                continue;
            findings.push({
                rule: 'R3',
                priority: 'normal',
                subjectId: it.initiativeId,
                title: `${it.title} — ${it.reason === 'needs-user' ? 'waiting on you' : 'drifting'}`,
                detail: it.detail || (it.reason === 'needs-user' ? 'An open decision is waiting on you.' : 'No update in a while.'),
                suggestedAction: 'review',
            });
        }
        return findings;
    }
    /** R4: spec approve-vs-change pattern — "you keep changing X the same way." */
    computeSpecPatternFindings() {
        if (!this.settings.rules.specPattern || !this.deps.approvalLedger)
            return [];
        let summaries;
        try {
            summaries = this.deps.approvalLedger.summarize();
        }
        catch (err) {
            // @silent-fallback-ok: input-read failure surfaced via onError; [] yields a
            // smaller digest (safe direction) for this observe-only component.
            this.deps.onError?.('approvalLedger.summarize', err);
            return [];
        }
        const s = this.settings;
        const findings = [];
        for (const cs of summaries) {
            if (cs.total < s.specPatternMinTotal)
                continue;
            const changeRatio = cs.total > 0 ? cs.approvedWithChange / cs.total : 0;
            if (changeRatio < s.specPatternMinChangeRatio)
                continue;
            const dominant = dominantDivergence(cs.divergenceCounts);
            if (!dominant)
                continue;
            findings.push({
                rule: 'R4',
                priority: 'low',
                subjectId: cs.decisionClass,
                title: `Spec pattern: '${cs.decisionClass}' keeps getting changed`,
                detail: `${cs.approvedWithChange}/${cs.total} '${cs.decisionClass}' decisions were approved-with-change, mostly '${dominant.category}' (${dominant.count}). Bake this into the default?`,
                suggestedAction: 'bake-in-default',
            });
        }
        return findings;
    }
    /** R5: correction pattern — a recurring correction worth surfacing. The
     *  CORRECTION-PREFERENCE spec owns the routing; this only SURFACES it. */
    computeCorrectionFindings(now) {
        if (!this.settings.rules.correctionPattern || !this.deps.correctionLedger)
            return [];
        let records;
        try {
            records = this.deps.correctionLedger.list({});
        }
        catch (err) {
            // @silent-fallback-ok: input-read failure surfaced via onError; [] yields a
            // smaller digest (safe direction) for this observe-only component.
            this.deps.onError?.('correctionLedger.list', err);
            return [];
        }
        const min = this.settings.correctionPatternMinOccurrences;
        const findings = [];
        for (const r of records) {
            // Only surface still-open recurring patterns (acted-on/verified are owned
            // by the correction loop's own lifecycle).
            if (r.status !== 'open' && r.status !== 'reopened')
                continue;
            if (r.occurrenceCount < min)
                continue;
            findings.push({
                rule: 'R5',
                priority: 'low',
                subjectId: r.dedupeKey,
                title: `Recurring correction (${r.occurrenceCount}×)`,
                // scrubbedSummary is the only HTTP-safe text on a correction record.
                detail: `${r.scrubbedSummary} — recurred ${r.occurrenceCount}×. Worth a durable preference or infra fix?`,
                suggestedAction: 'route-correction',
            });
        }
        return findings;
    }
    /** Run the full observation + computation pass. Returns all findings. */
    computeFindings(now = this.now()) {
        const journal = this.observeStages(now);
        return [
            ...this.computeRolloutFindings(now, journal),
            ...this.computeStallingFindings(now),
            ...this.computeSpecPatternFindings(),
            ...this.computeCorrectionFindings(now),
            ...this.computeDevGateConformanceFindings(),
        ];
    }
    /** R6 (dev-gate conformance): on a development agent, every registered
     *  dev-gated feature MUST resolve LIVE. One observed DARK means it was wired
     *  wrong / misconfigured (a hardcoded enabled:false default, or an operator
     *  override) — the forgot-the-gate class the Slice-1 lint can't see and the
     *  Slice-2 default test only catches at the default level. Surfacing it here
     *  makes it a live finding on the very dev agent it affects.
     *  Spec: DEV-AGENT-DARK-GATE-CONFORMANCE-SPEC (Slice 3). */
    computeDevGateConformanceFindings() {
        if (!this.settings.rules.devGateConformance)
            return [];
        const cfg = this.deps.liveConfig;
        if (!cfg || cfg.developmentAgent !== true)
            return []; // only meaningful on a dev agent
        const findings = [];
        for (const feature of DEV_GATED_FEATURES) {
            let live;
            try {
                const explicit = getConfigByPath(cfg, feature.configPath);
                live = resolveDevAgentGate(explicit, cfg);
            }
            catch (err) {
                this.deps.onError?.('devGateConformance', err);
                continue;
            }
            if (!live) {
                findings.push({
                    rule: 'R6',
                    priority: 'normal',
                    subjectId: feature.name,
                    title: `Dev-gated feature "${feature.name}" is DARK on this dev agent`,
                    detail: `${feature.description} It follows the developmentAgent dark-feature gate, ` +
                        `so it should run LIVE on a development agent — but the live config resolves ` +
                        `it DARK at ${feature.configPath}. Likely a hardcoded enabled:false default or ` +
                        `an operator override (the forgot-the-gate / misconfig class the lint can't see).`,
                    suggestedAction: 'review',
                });
            }
        }
        return findings;
    }
    /** Build the operator-facing digest. Calm digests still render so the operator
     *  knows the analyst ran (fixes the old "near-silent → never speaks" failure). */
    buildDigest(now = this.now()) {
        const journal = this.observeStages(now);
        const rollout = this.computeRolloutFindings(now, journal);
        const stalling = this.computeStallingFindings(now);
        const spec = this.computeSpecPatternFindings();
        const corr = this.computeCorrectionFindings(now);
        const devGate = this.computeDevGateConformanceFindings();
        const findings = [...rollout, ...stalling, ...spec, ...corr, ...devGate];
        const counts = {
            incubating: this.countIncubating(now, journal),
            promotionReady: rollout.filter((f) => f.rule === 'R1').length,
            expiredUnproven: rollout.filter((f) => f.rule === 'R2').length,
            stalling: stalling.length,
            specPatterns: spec.length,
            correctionPatterns: corr.length,
            devGateDark: devGate.length,
        };
        const nextWindowClosesInDays = this.nextWindowClose(now, journal);
        const calm = findings.length === 0;
        const summary = calm
            ? this.calmSummary(counts, nextWindowClosesInDays)
            : this.activeSummary(counts);
        return {
            generatedAt: now.toISOString(),
            calm,
            summary,
            findings,
            counts,
            nextWindowClosesInDays,
        };
    }
    /** Lightweight status for a GET route / dashboard. */
    getStatus(now = this.now()) {
        const digest = this.buildDigest(now);
        return {
            enabled: this.isEnabled(),
            settings: this.settings,
            counts: digest.counts,
            nextWindowClosesInDays: digest.nextWindowClosesInDays,
        };
    }
    // ── digest helpers ──────────────────────────────────────────────────────
    countIncubating(now, journal) {
        let n = 0;
        for (const init of this.stagedInitiatives()) {
            const obs = journal[init.id];
            if (!obs)
                continue;
            const tier = riskTierForInitiative(init);
            const windowDays = this.settings.incubationWindows[tier];
            const v = classifyRollout(init.rollout.stage, daysSince(obs.firstObservedAt, now), windowDays, this.safeProof(init), this.settings.proofOfLifeMinActivations);
            if (v.classification === 'incubating')
                n++;
        }
        return n;
    }
    nextWindowClose(now, journal) {
        let soonest;
        for (const init of this.stagedInitiatives()) {
            const obs = journal[init.id];
            if (!obs)
                continue;
            const tier = riskTierForInitiative(init);
            const windowDays = this.settings.incubationWindows[tier];
            const daysInStage = daysSince(obs.firstObservedAt, now);
            if (init.rollout.stage === 'default-on')
                continue;
            if (daysInStage >= windowDays)
                continue; // already expired
            const remaining = windowDays - daysInStage;
            if (soonest === undefined || remaining < soonest)
                soonest = remaining;
        }
        return soonest === undefined ? undefined : Math.round(soonest * 10) / 10;
    }
    safeProof(init) {
        try {
            return this.deps.evidenceCounter?.(init);
        }
        catch {
            // @silent-fallback-ok: count-only helper for the calm-digest incubating
            // tally; undefined → proved:'unknown' (the honest conservative result).
            // Observe-only — never gates an action.
            return undefined;
        }
    }
    calmSummary(counts, nextClose) {
        const base = `All healthy — ${counts.incubating} feature(s) incubating, nothing past its window.`;
        if (nextClose !== undefined)
            return `${base} Next window closes in ${nextClose}d.`;
        return base;
    }
    activeSummary(counts) {
        const parts = [];
        if (counts.promotionReady)
            parts.push(`${counts.promotionReady} ready to promote`);
        if (counts.expiredUnproven)
            parts.push(`${counts.expiredUnproven} expired-unproven`);
        if (counts.stalling)
            parts.push(`${counts.stalling} stalling`);
        if (counts.specPatterns)
            parts.push(`${counts.specPatterns} spec pattern(s)`);
        if (counts.correctionPatterns)
            parts.push(`${counts.correctionPatterns} correction pattern(s)`);
        if (counts.devGateDark)
            parts.push(`${counts.devGateDark} dev-gated-dark`);
        return `Growth digest: ${parts.join(', ')}.`;
    }
}
/** The divergence category with the highest count (ties → first by declaration),
 *  or null when there are no divergences at all. PURE. */
export function dominantDivergence(counts) {
    let best = null;
    for (const [category, count] of Object.entries(counts)) {
        if (count <= 0)
            continue;
        if (!best || count > best.count)
            best = { category, count };
    }
    return best;
}
//# sourceMappingURL=GrowthMilestoneAnalyst.js.map