# All files / src/tools monitorLegislativePipeline.ts

89.27% Statements 208/233
84.92% Branches 169/199
97.95% Functions 48/49
92.82% Lines 181/195

Press n or j to go to the next uncovered block, b, p or k for the previous block.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    329
    330
    331
    332
    333
    334
    335
    336
    337
    338
    339
    340
    341
    342
    343
    344
    345
    346
    347
    348
    349
    350
    351
    352
    353
    354
    355
    356
    357
    358
    359
    360
    361
    362
    363
    364
    365
    366
    367
    368
    369
    370
    371
    372
    373
    374
    375
    376
    377
    378
    379
    380
    381
    382
    383
    384
    385
    386
    387
    388
    389
    390
    391
    392
    393
    394
    395
    396
    397
    398
    399
    400
    401
    402
    403
    404
    405
    406
    407
    408
    409
    410
    411
    412
    413
    414
    415
    416
    417
    418
    419
    420
    421
    422
    423
    424
    425
    426
    427
    428
    429
    430
    431
    432
    433
    434
    435
    436
    437
    438
    439
    440
    441
    442
    443
    444
    445
    446
    447
    448
    449
    450
    451
    452
    453
    454
    455
    456
    457
    458
    459
    460
    461
    462
    463
    464
    465
    466
    467
    468
    469
    470
    471
    472
    473
    474
    475
    476
    477
    478
    479
    480
    481
    482
    483
    484
    485
    486
    487
    488
    489
    490
    491
    492
    493
    494
    495
    496
    497
    498
    499
    500
    501
    502
    503
    504
    505
    506
    507
    508
    509
    510
    511
    512
    513
    514
    515
    516
    517
    518
    519
    520
    521
    522
    523
    524
    525
    526
    527
    528
    529
    530
    531
    532
    533
    534
    535
    536
    537
    538
    539
    540
    541
    542
    543
    544
    545
    546
    547
    548
    549
    550
    551
    552
    553
    554
    555
    556
    557
    558
    559
    560
    561
    562
    563
    564
    565
    566
    567
    568
    569
    570
    571
    572
    573
    574
    575
    576
    577
    578
    579
    580
    581
    582
    583
    584
    585
    586
    587
    588
    589
    590
    591
    592
    593
    594
    595
    596
    597
    598
    599
    600
    601
    602
    603
    604
    605
    606
    607
    608
    609
    610
    611
    612
    613
    614
    615
    616
    617
    618
    619
    620
    621
    622
    623
    624
    625
    626
    627
    628
    629
    630
    631
    632
    633
    634
    635
    636
    637
    638
    639
    640
    641
    642
    643
    644
    645
    646
    647
    648
    649
    650
    651
    652
    653
    654
    655
    656
    657
    658
    659
    660
    661
    662
    663
    664
    665
    666
    667
    668
    669
    670
    671
    672
    673
    674
    675
    676
    677
    678
    679
    680
    681
    682
    683
    684
    685
    686
    687
    688
    689
    690
    691
    692
    693
    694
    695
    696
    697
    698
    699
    700
    701
    702
    703
    704
    705
    706
    707
    708
    709
    710
    711
    712
    713
    714
    715
    716
    717
    718
    719
    720
    721
    722
    723
    724
    725
    726
    727
    728
    729
    730
    731
    732
    733
    734
    735
    736
    737
    738
    739
    740
    741
    742
    743
    744
    745
    746
    747
    748
    749
    750
    751
    752
    753
    754
    755
    756
    757
    758
    759
    760
    761
    762
    763
    764
    765
    766
    767
    768
    769
    770
    771
    772
    773
    774
    775
    776
    777
    778
    779
    780
    781
    782
    783
    784
    785
    786
    787
    788
    789
    790
    791
    792
    793
    794
    795
    796
    797
    798
    799
    800
    801
    802
    803
    804
    805
    806
    807
    808
    809
    810
    811
    812
    813
    814
    815
    816
    817
    818
    819
    820
    821
    822
    823
    824
    825
    826
    827
    828
    829
    830
    831
    832
    833
    834
    835
    836
    837
    838
    839
    840
    841
    842
    843
    844
    845
    846
    847
    848
    849
    850
    851
    852
    853
    854
    855
    856
    857
    858
    859
    860
    861
    862
    863
    864
    865
    866
    867
    868
    869
    870
    871
    872
    873
    874
    875
    876
    877
    878
    879
    880
    881
    882
    883
    884
    885
    886
    887
    888
    889
    890
    891
    892
    893
    894
    895
    896
    897
    898
    899
    900
    901
    902
    903
    904
    905
    906
    907
    908
    909
    910
    911
    912
    913
    914
    915
    916
    917
    918
    919
    920
    921
    922
    923
    924
    925
    926
    927
    928
    929
    930
    931
    932
    933
    934
    935
    936
    937
    938
    939
    940
    941
    942
    943
    944
    945
    946
    947
    948
    949
    950
    951
    952
    953
    954
    955
    956
    957
    958
    959
    960
    961

    4x

    4x

    4x

    230x
    230x
    230x
    226x
    226x
    226x
    226x
    226x

    115x
    17x
    17x

    115x
    9x

    106x
    70x
    17x

    28x
    28x
    28x

    36x
    19x
    9x
    9x

    115x
    115x

    115x
    46x
    46x
    46x

    69x
    115x

    115x
    62x
    9x
    9x

    53x

    115x
    115x
    115x

    115x
    115x
    115x
    115x
    115x
    115x
    115x
    115x
    115x

    118x

    115x
    69x
    10x
    10x

    115x
    115x

    115x
    115x

    115x
    115x
    115x
    115x

    115x

    38x
    34x
    38x

    118x
    115x
    115x

    115x
    40x
    37x

    3x

    83x

    9x
    9x

    83x
    9x

    74x

    36x
    36x
    83x
    83x
    28x

    83x
    83x
    83x

    28x

    36x
    28x

    9x
    9x

    83x
    83x
    83x
    83x
    36x
    36x

    36x
    36x
    36x

    36x

    83x
    36x
    39x

    36x
    36x
    36x

    36x
    5x

    36x
    25x

    36x
    18x

    36x

    36x
    36x
    36x
    36x
    36x
    36x

    74x
    37x
    37x
    37x
    37x
    37x

    37x
    37x
    37x
    37x
    13x

    37x

    37x

    36x
    126x
    126x
    126x
    8x

    118x

    36x
    36x
    36x
    36x
    115x
    115x
    69x

    115x
    115x
    115x
    115x

    36x

    37x
    37x
    37x

    37x

    37x
    37x

    36x
    36x

    36x

    36x
    37x

    37x
    115x
    83x

    37x
    37x
    83x
    83x
    37x

    37x

    38x

    38x
    38x

    36x

    1x

    1x

    4x

     /**
     * MCP Tool: monitor_legislative_pipeline
     *
     * Real-time legislative pipeline status with bottleneck detection and
     * timeline forecasting backed by the authoritative procedure-event
     * lifecycle (`/procedures/{id}/events`).
     *
     * **Intelligence Perspective:** Pipeline monitoring tool providing situational
     * awareness of legislative progress — enables early warning for stalled
     * procedures, bottleneck identification, and timeline forecasting based on
     * historical median dwell statistics rather than heuristics.
     *
     * **Methodology:**
     * 1. Load the corpus-wide lifecycle model from cache only
     *    (`getCachedLifecycleStatistics`). The corpus rebuild
     *    (`/procedures` + up to 500 `/procedures/{id}/events`) is far too
     *    expensive for the request path under the EP API's 100 req/min rate
     *    limit — even with a cooperative deadline, in-flight requests cannot be
     *    cancelled and the budget's worth of token-bucket spend would starve
     *    the request's own `/procedures` + `/events` fan-out. On a cache miss
     *    the empty model is used and per-procedure forecasts gracefully degrade
     *    to `INSUFFICIENT_DATA`. Out-of-band warmup (e.g. background jobs or
     *    sibling tools whose primary purpose is the corpus itself) populates
     *    the 30-min cache via `getLifecycleStatistics`.
     * 2. For each procedure in scope, fetch its event timeline with bounded
     *    concurrency (≤8 parallel) and `Promise.allSettled`.
     * 3. `daysInCurrentStage` = days between the latest event and `now`.
     * 4. `bottleneckRisk` = percentile bucket of the current dwell vs. the
     *    historical distribution for the same `(procedureType, stage)`.
     * 5. `estimatedCompletionDays` = historical median remaining days from the
     *    current stage (falls back to a heuristic when the corpus lacks data,
     *    in which case `forecastBasis` is `INSUFFICIENT_DATA`).
     *
     * ISMS Policy: SC-002 (Input Validation), AC-003 (Least Privilege)
     */

    import { MonitorLegislativePipelineSchema } from '../schemas/europeanParliament.js';
    import { epClient } from '../clients/europeanParliamentClient.js';
    import type { Procedure, EPEvent } from '../types/europeanParliament.js';
    import type { ToolResult } from './shared/types.js';
    import { withTimeout, TimeoutError } from '../utils/timeout.js';
    // (buildTimeoutResponse no longer used directly — see buildPipelineTimeoutResponse)
    import { ToolError } from './shared/errors.js';
    import {
      getCachedLifecycleStatistics,
      emptyLifecycleStatisticsModel,
      fetchEventsBounded,
      sortEventsChronologically,
      normalizeStageKey,
      lookupStageStatistics,
      type LifecycleStatisticsModel,
      type StageDwellStatistics,
    } from '../utils/lifecycleStatistics.js';

    /**
     * Maximum wall-clock time (ms) allowed for the full
     * `monitor_legislative_pipeline` operation. Chosen to align with sibling
     * intelligence tools and stay below the integration-test 120 s ceiling.
     */
    const OPERATION_TIMEOUT_MS = 30_000;

    /** Forecast basis discriminator emitted in the response envelope. */
    export type ForecastBasis = 'HISTORICAL_MEDIAN' | 'INSUFFICIENT_DATA' | 'NOT_APPLICABLE';

    /** A single lifecycle event echoed for traceability. */
    export interface PipelineLifecycleEvent {
      date: string;
      stage: string;
      rawType: string;
      title: string;
    }

    /** Computed attributes for a single pipeline item */
    interface PipelineItemComputedAttrs {
      progressPercentage: number;
      velocityScore: number;
      complexityIndicator: string;
      estimatedCompletionDays: number;
      bottleneckRisk: string;
    }

    /** A single procedure in the pipeline */
    interface PipelineItem {
      procedureId: string;
      title: string;
      type: string;
      currentStage: string;
      committee: string;
      daysInCurrentStage: number;
      isStalled: boolean;
      nextExpectedAction: string;
      computedAttributes: PipelineItemComputedAttrs;
      lifecycleEvents: PipelineLifecycleEvent[];
    }

    /** Bottleneck analysis */
    interface BottleneckInfo {
      stage: string;
      procedureCount: number;
      avgDaysStuck: number;
      severity: string;
      /** Historical 95th-percentile dwell (days) for the stage — bottleneck threshold. */
      thresholdDays: number;
    }

    /** Full pipeline analysis result */
    interface LegislativePipelineAnalysis {
      period: { from: string; to: string };
      filter: { committee?: string; status: string };
      pipeline: PipelineItem[];
      summary: {
        totalProcedures: number;
        activeCount: number;
        stalledCount: number;
        completedCount: number;
        avgDaysInPipeline: number;
      };
      bottlenecks: BottleneckInfo[];
      computedAttributes: {
        pipelineHealthScore: number;
        throughputRate: number;
        bottleneckIndex: number;
        stalledProcedureRate: number;
        estimatedClearanceTime: number;
        legislativeMomentum: string;
      };
      confidenceLevel: 'HIGH' | 'MEDIUM' | 'LOW';
      dataFreshness: string;
      sourceAttribution: string;
      methodology: string;
      dataQualityWarnings: string[];
      forecastBasis: ForecastBasis;
      lifecycleCorpus: {
        corpusSize: number;
        totalObservations: number;
        computationTimeMs: number;
      };
    }

    /**
     * Default recency window (years) applied to the ACTIVE status filter when no
     * explicit dateFrom is provided. Procedures whose best available date predates
     * this window cannot be confirmed as currently active and are excluded.
     */
    const ACTIVE_RECENCY_YEARS = 10;

    /**
     * Minimum sample size for a `(type, stage)` cell to be treated as a reliable
     * forecasting baseline. Below this the cell is ignored and we fall back to
     * the heuristic forecast.
     */
    const MIN_SAMPLE_SIZE_FOR_FORECAST = 3;

    /**
     * Calculate whole-day delta between two date strings (or between a date and
     * "now" when `endStr` is omitted). Uses UTC day boundaries with `Math.floor`
     * to match {@link import('../utils/lifecycleStatistics.js').daysBetween}, so
     * the current-dwell value compared against `p95DwellDays` / `medianDwellDays`
     * is computed on the same scale as the corpus statistics.
     */
    function daysBetween(dateStr: string, endStr?: string): number {
      const start = new Date(dateStr);
      const end = endStr !== undefined && endStr !== '' ? new Date(endStr) : new Date();
      if (isNaN(start.getTime())) return 0;
      Iif (isNaN(end.getTime())) return 0;
      const msPerDay = 1000 * 60 * 60 * 24;
      const startUtcDays = Math.floor(start.getTime() / msPerDay);
      const endUtcDays = Math.floor(end.getTime() / msPerDay);
      return Math.max(0, endUtcDays - startUtcDays);
    }

    /** Classify complexity from days in stage */
    function classifyComplexity(days: number): string {
      if (days > 60) return 'HIGH';
      Iif (days > 30) return 'MEDIUM';
      return 'LOW';
    }

    /**
     * Classify bottleneck risk by comparing the current dwell to the historical
     * distribution for `(procedureType, stage)`. Falls back to the legacy
     * heuristic when no statistics are available so the tool degrades gracefully.
     */
    function classifyBottleneckRisk(
      days: number,
      stats: StageDwellStatistics | undefined,
      isStalledHeuristic: boolean
    ): string {
      if (stats !== undefined && stats.sampleSize > 0) {
        Eif (days >= stats.p95DwellDays) return 'HIGH';
        if (days >= stats.medianDwellDays) return 'MEDIUM';
        return 'LOW';
      }
      if (isStalledHeuristic) return 'HIGH';
      if (days > 45) return 'MEDIUM';
      return 'LOW';
    }

    /** Classify bottleneck severity */
    function classifyBottleneckSeverity(count: number): string {
      Iif (count > 3) return 'CRITICAL';
      Iif (count > 1) return 'HIGH';
      return 'MODERATE';
    }

    /** Classify legislative momentum */
    function classifyMomentum(healthScore: number): string {
      if (healthScore > 80) return 'STRONG';
      if (healthScore > 60) return 'MODERATE';
      Iif (healthScore > 40) return 'SLOW';
      return 'STALLED';
    }

    /**
     * Check if a procedure status indicates completion.
     */
    function isStatusCompleted(status: string): boolean {
      const lower = status.toLowerCase();
      return lower.includes('adopted') || lower.includes('completed') ||
        lower.includes('rejected') || lower.includes('withdrawn') || lower.includes('closed');
    }

    /**
     * Compute the dwell time at the current stage from the latest event date.
     * Falls back to the procedure's `dateLastActivity` (then `dateInitiated`)
     * when no events are available so behavior remains defined when the
     * `/events` endpoint is unreachable for a given procedure.
     */
    function computeDwellFromEvents(
      proc: Procedure,
      sortedEvents: readonly EPEvent[]
    ): { daysInStage: number; latestEventDate: string; latestStage: string } {
      if (sortedEvents.length > 0) {
        const latest = sortedEvents[sortedEvents.length - 1];
        Eif (latest !== undefined) {
          return {
            daysInStage: daysBetween(latest.date),
            latestEventDate: latest.date,
            latestStage: normalizeStageKey(latest.type),
          };
        }
      }
      const fallback = proc.dateLastActivity !== '' ? proc.dateLastActivity : proc.dateInitiated;
      return {
        daysInStage: daysBetween(fallback),
        latestEventDate: fallback,
        latestStage: '',
      };
    }

    /**
     * Forecast the remaining days until completion, preferring the historical
     * median for the matching `(procedureType, stage)` cell.
     */
    function forecastRemainingDays(
      isCompleted: boolean,
      daysInStage: number,
      stats: StageDwellStatistics | undefined
    ): { estimatedDays: number; basis: ForecastBasis } {
      if (isCompleted) return { estimatedDays: 0, basis: 'NOT_APPLICABLE' };
      if (stats !== undefined && stats.sampleSize >= MIN_SAMPLE_SIZE_FOR_FORECAST) {
        const remaining = Math.max(0, stats.medianRemainingDays - daysInStage);
        return { estimatedDays: remaining, basis: 'HISTORICAL_MEDIAN' };
      }
      return { estimatedDays: Math.max(30, daysInStage * 2), basis: 'INSUFFICIENT_DATA' };
    }

    /**
     * Compute progress metrics for a procedure using the real event timeline.
     */
    function computePipelineMetrics(
      proc: Procedure,
      sortedEvents: readonly EPEvent[],
      stats: StageDwellStatistics | undefined
    ): {
      daysInStage: number; isCompleted: boolean; isStalled: boolean;
      totalDays: number; progressEstimate: number; velocityScore: number;
      estimatedDays: number; basis: ForecastBasis; latestStage: string; latestEventDate: string;
    } {
      const dwell = computeDwellFromEvents(proc, sortedEvents);
      const isCompleted = isStatusCompleted(proc.status);
      const isStalledByStats = stats !== undefined && stats.sampleSize > 0
        ? dwell.daysInStage >= stats.p95DwellDays
        : false;
      const isStalledByHeuristic = !isCompleted && dwell.daysInStage > 60;
      const isStalled = !isCompleted && (isStalledByStats || isStalledByHeuristic);
      const initiated = proc.dateInitiated !== '' ? proc.dateInitiated : '';
      const lastAct = proc.dateLastActivity !== '' ? proc.dateLastActivity : undefined;
      const totalDays = daysBetween(initiated, lastAct);
      const progressEstimate = isCompleted ? 100 : Math.min(90, Math.max(5, Math.round(totalDays / 10)));
      const velocityScore = isStalled ? 20 : Math.min(100, 100 - Math.min(80, dwell.daysInStage));
      const { estimatedDays, basis } = forecastRemainingDays(isCompleted, dwell.daysInStage, stats);
      return {
        daysInStage: dwell.daysInStage,
        isCompleted,
        isStalled,
        totalDays,
        progressEstimate,
        velocityScore,
        estimatedDays,
        basis,
        latestStage: dwell.latestStage,
        latestEventDate: dwell.latestEventDate,
      };
    }

    /**
     * Build the lifecycleEvents array echoed back to callers for traceability.
     * The events are returned in chronological order, normalised but preserving
     * the original raw type for downstream consumers.
     */
    function toLifecycleEvents(sortedEvents: readonly EPEvent[]): PipelineLifecycleEvent[] {
      return sortedEvents.map((e) => ({
        date: e.date,
        stage: normalizeStageKey(e.type),
        rawType: e.type,
        title: e.title,
      }));
    }

    /**
     * Resolve the procedure's display stage, preferring the latest event stage,
     * then the procedure's metadata stage/status fields. Falls back to `'Unknown'`
     * when no stage signal is available.
     */
    function resolveCurrentStage(latestStage: string, proc: Procedure): string {
      if (latestStage !== '') return latestStage;
      if (proc.stage !== '') return proc.stage;
      Iif (proc.status !== '') return proc.status;
      return 'Unknown';
    }

    /**
     * Transform a real EP API Procedure into a PipelineItem, using the procedure's
     * lifecycle events when available.
     */
    function procedureToPipelineItem(
      proc: Procedure,
      sortedEvents: readonly EPEvent[],
      model: LifecycleStatisticsModel
    ): { item: PipelineItem; basis: ForecastBasis } {
      const procedureType = proc.type !== '' ? proc.type : 'UNKNOWN';
      const stageFromEvents = sortedEvents.length > 0
        ? normalizeStageKey(sortedEvents[sortedEvents.length - 1]?.type ?? '')
        : '';
      const stats = lookupStageStatistics(model, procedureType, stageFromEvents);
      const m = computePipelineMetrics(proc, sortedEvents, stats);

      const currentStage = resolveCurrentStage(m.latestStage, proc);
      const committee = proc.responsibleCommittee !== '' ? proc.responsibleCommittee : 'Unknown';
      const stageLabel = currentStage !== 'Unknown' ? currentStage : 'processing';
      const nextAction = m.isCompleted ? 'COMPLETED' : `Continue ${stageLabel}`;

      return {
        item: {
          procedureId: proc.id,
          title: proc.title,
          type: proc.type,
          currentStage,
          committee,
          daysInCurrentStage: m.daysInStage,
          isStalled: m.isStalled,
          nextExpectedAction: nextAction,
          computedAttributes: {
            progressPercentage: m.progressEstimate,
            velocityScore: m.velocityScore,
            complexityIndicator: classifyComplexity(m.daysInStage),
            estimatedCompletionDays: m.estimatedDays,
            bottleneckRisk: classifyBottleneckRisk(m.daysInStage, stats, m.isStalled),
          },
          lifecycleEvents: toLifecycleEvents(sortedEvents),
        },
        basis: m.basis,
      };
    }

    /**
     * Check if a procedure's dates pass the recency cut-off for the ACTIVE filter.
     * Returns false if the procedure has no temporal data or is older than the cut-off.
     */
    function isWithinRecencyCutoff(
      lastActivity: string,
      initiated: string | undefined,
      cutoffDate: string
    ): boolean {
      if (lastActivity === '' && initiated === undefined) return false;
      const referenceDate = lastActivity !== '' ? lastActivity : initiated;
      return referenceDate !== undefined && referenceDate >= cutoffDate;
    }

    /** Check if a procedure matches an explicit date range (dateFrom / dateTo) */
    function matchesDateRange(
      lastActivity: string,
      initiated: string | undefined,
      dateFrom: string | undefined,
      dateTo: string | undefined
    ): boolean {
      if (dateFrom !== undefined && lastActivity !== '' && lastActivity < dateFrom) return false;
      Iif (dateTo !== undefined && initiated !== undefined && initiated > dateTo) return false;
      return true;
    }

    /** Check if item matches status filter */
    function matchesStatusFilter(item: PipelineItem, status: string): boolean {
      if (status === 'ALL') return true;
      if (status === 'ACTIVE') {
        return !item.isStalled
          && item.computedAttributes.progressPercentage < 100
          && item.currentStage !== 'Unknown';
      }
      Eif (status === 'STALLED') return item.isStalled;
      if (status === 'COMPLETED') return item.computedAttributes.progressPercentage >= 100;
      return true;
    }

    /** Check if item matches committee filter */
    function matchesCommitteeFilter(item: PipelineItem, committee: string | undefined): boolean {
      Eif (committee === undefined) return true;
      return item.committee === committee;
    }

    /**
     * Compare a stage string for sort stability (named to avoid nested ternaries).
     */
    function compareStage(a: string, b: string): number {
      Iif (a < b) return -1;
      Eif (a > b) return 1;
      return 0;
    }

    /**
     * Decide whether a pipeline item should be counted as a bottleneck.
     *
     * Returns `true` when the procedure's dwell at its current stage is at or
     * above the historical 95th percentile, or — when no statistics exist for the
     * `(type, stage)` cell — when the legacy stalled heuristic applies.
     */
    function isBottleneckItem(
      item: PipelineItem,
      stats: StageDwellStatistics | undefined,
    ): boolean {
      if (stats !== undefined && stats.sampleSize > 0) {
        return item.daysInCurrentStage >= stats.p95DwellDays;
      }
      return stats === undefined && item.isStalled;
    }

    interface BottleneckBucket {
      count: number;
      totalDays: number;
      thresholdDays: number;
    }

    /**
     * Detect bottlenecks from pipeline items whose dwell at the current stage is
     * at or above the historical 95th percentile. Procedures with no matching
     * statistics fall back to the legacy `isStalled` heuristic so the tool keeps
     * producing a useful list even when the corpus is empty.
     *
     * **Cross-type aggregation:** bottlenecks are keyed by **stage label only**
     * (e.g. `"REFERRAL"`), not `(type, stage)`. This is intentional — callers
     * looking at "which stage is stuck right now" want a single view across
     * procedure types. The per-procedure `bottleneckRisk` field (and the
     * underlying p95 cell) remains type-aware, so the precise threshold for an
     * individual procedure is unaffected by cross-type aggregation here.
     * `thresholdDays` reports the **maximum** p95 seen across the contributing
     * `(type, stage)` cells — interpret it as a conservative high-water mark
     * for the worst type in that stage bucket.
     */
    function detectBottlenecks(pipeline: PipelineItem[], model: LifecycleStatisticsModel): BottleneckInfo[] {
      const stageBuckets: Record<string, BottleneckBucket> = {};
      for (const item of pipeline) {
        const stats = lookupStageStatistics(model, item.type !== '' ? item.type : 'UNKNOWN', item.currentStage);
        if (!isBottleneckItem(item, stats)) continue;
        const entry = stageBuckets[item.currentStage] ?? {
          count: 0,
          totalDays: 0,
          thresholdDays: stats?.p95DwellDays ?? 0,
        };
        entry.count++;
        entry.totalDays += item.daysInCurrentStage;
        Iif (stats !== undefined && stats.p95DwellDays > entry.thresholdDays) {
          entry.thresholdDays = stats.p95DwellDays;
        }
        stageBuckets[item.currentStage] = entry;
      }
      return Object.entries(stageBuckets)
        .map(([stage, data]) => ({
          stage,
          procedureCount: data.count,
          avgDaysStuck: Math.round(data.totalDays / data.count),
          severity: classifyBottleneckSeverity(data.count),
          thresholdDays: data.thresholdDays,
        }))
        .sort((a, b) => {
          Iif (b.procedureCount !== a.procedureCount) return b.procedureCount - a.procedureCount;
          return compareStage(a.stage, b.stage);
        });
    }

    /** Compute pipeline summary statistics */
    function computePipelineSummary(pipeline: PipelineItem[]): {
      activeCount: number; stalledCount: number; completedCount: number; avgDays: number;
    } {
      const activeCount = pipeline.filter(p => !p.isStalled && p.computedAttributes.progressPercentage < 100).length;
      const stalledCount = pipeline.filter(p => p.isStalled).length;
      const completedCount = pipeline.filter(p => p.computedAttributes.progressPercentage >= 100).length;
      const totalDays = pipeline.reduce((sum, p) => sum + p.daysInCurrentStage, 0);
      const avgDays = pipeline.length > 0 ? Math.round(totalDays / pipeline.length) : 0;
      return { activeCount, stalledCount, completedCount, avgDays };
    }

    /** Compute pipeline health metrics */
    function computeHealthMetrics(pipeline: PipelineItem[], summary: ReturnType<typeof computePipelineSummary>): {
      healthScore: number; throughputRate: number; stalledRate: number;
    } {
      const stalledRate = pipeline.length > 0 ? summary.stalledCount / pipeline.length : 0;
      const healthScore = Math.round((1 - stalledRate) * 100 * 100) / 100;
      const throughputRate = pipeline.length > 0
        ? Math.round((summary.completedCount / pipeline.length) * 100 * 100) / 100
        : 0;
      return { healthScore, throughputRate, stalledRate };
    }

    /**
     * Aggregate the per-procedure forecast bases into a single envelope-level
     * basis. If any procedure used a historical median forecast we report
     * `HISTORICAL_MEDIAN`; otherwise we report `INSUFFICIENT_DATA`.
     */
    function aggregateForecastBasis(perItemBases: readonly ForecastBasis[]): ForecastBasis {
      // Exclude NOT_APPLICABLE (completed procedures) from the envelope-level determination
      const actionable = perItemBases.filter((b) => b !== 'NOT_APPLICABLE');
      if (actionable.length === 0) return 'NOT_APPLICABLE';
      return actionable.some((b) => b === 'HISTORICAL_MEDIAN')
        ? 'HISTORICAL_MEDIAN'
        : 'INSUFFICIENT_DATA';
    }

    /**
     * Build the human-readable list of data-quality warnings appended to the
     * response envelope.
     */
    function buildWarnings(
      pipelineSize: number,
      envelopeBasis: ForecastBasis,
      unknownEnrichmentCount: number,
      eventFetchFailures: number,
    ): string[] {
      const warnings: string[] = [];
      Eif (pipelineSize < 10) {
        warnings.push('Small procedure sample (< 10) — pipeline health metrics may not be statistically representative');
      }
      if (unknownEnrichmentCount > 0) {
        warnings.push(`${String(unknownEnrichmentCount)} procedure(s) excluded from ACTIVE filter due to missing enrichment data (stage/committee unknown) — these may be historical or incomplete records`);
      }
      if (eventFetchFailures > 0) {
        warnings.push(`${String(eventFetchFailures)} procedure(s) had no lifecycle events available — fell back to procedure-metadata dates for dwell computation`);
      }
      if (envelopeBasis === 'INSUFFICIENT_DATA') {
        warnings.push('Forecast basis: insufficient historical lifecycle data — `estimatedCompletionDays` uses a heuristic fallback');
      }
      return warnings;
    }

    interface BuildAnalysisInput {
      reportFrom: string;
      reportTo: string;
      params: ReturnType<typeof MonitorLegislativePipelineSchema.parse>;
      pipeline: PipelineItem[];
      model: LifecycleStatisticsModel;
      envelopeBasis: ForecastBasis;
      unknownEnrichmentCount: number;
      eventFetchFailures: number;
    }

    /**
     * Build the analysis envelope.
     */
    function buildAnalysis(input: BuildAnalysisInput): LegislativePipelineAnalysis {
      const { reportFrom, reportTo, params, pipeline, model, envelopeBasis,
        unknownEnrichmentCount, eventFetchFailures } = input;
      const summary = computePipelineSummary(pipeline);
      const bottlenecks = detectBottlenecks(pipeline, model);
      const health = computeHealthMetrics(pipeline, summary);
      const warnings = buildWarnings(pipeline.length, envelopeBasis, unknownEnrichmentCount, eventFetchFailures);
      return {
        period: { from: reportFrom, to: reportTo },
        filter: { ...(params.committee !== undefined ? { committee: params.committee } : {}), status: params.status },
        pipeline,
        summary: {
          totalProcedures: pipeline.length,
          activeCount: summary.activeCount,
          stalledCount: summary.stalledCount,
          completedCount: summary.completedCount,
          avgDaysInPipeline: summary.avgDays,
        },
        bottlenecks,
        computedAttributes: {
          pipelineHealthScore: health.healthScore,
          throughputRate: health.throughputRate,
          bottleneckIndex: Math.round(health.stalledRate * summary.avgDays * 100) / 100,
          stalledProcedureRate: Math.round(health.stalledRate * 100 * 100) / 100,
          estimatedClearanceTime: summary.avgDays * Math.max(1, summary.activeCount),
          legislativeMomentum: classifyMomentum(health.healthScore),
        },
        confidenceLevel: pipeline.length >= 10 && envelopeBasis === 'HISTORICAL_MEDIAN'
          ? 'MEDIUM'
          : 'LOW',
        dataFreshness: 'Real-time EP API data — procedures from /procedures and lifecycle from /procedures/{id}/events',
        sourceAttribution: 'European Parliament Open Data Portal - data.europarl.europa.eu',
        methodology: 'Lifecycle-driven pipeline analysis using EP API /procedures and /procedures/{id}/events. '
          + 'For each procedure in scope the authoritative event sequence is fetched in parallel (bounded ≤8). '
          + '`daysInCurrentStage` is the delta between the most recent event and now. '
          + '`bottleneckRisk` is a percentile bucket of the current dwell against the historical distribution '
          + 'for the same (procedureType, stage) observed across the latest 500 procedures (30 min cache). '
          + '`estimatedCompletionDays` uses the historical median remaining-time at the current stage; '
          + 'when no comparable historical sample exists the response is flagged `INSUFFICIENT_DATA`. '
          + 'Bottlenecks are aggregated from procedures whose dwell is ≥ the 95th percentile.',
        dataQualityWarnings: warnings,
        forecastBasis: envelopeBasis,
        lifecycleCorpus: {
          corpusSize: model.corpusSize,
          totalObservations: model.totalObservations,
          computationTimeMs: model.computationTimeMs,
        },
      };
    }

    interface OperationContext {
      params: ReturnType<typeof MonitorLegislativePipelineSchema.parse>;
      reportFrom: string;
      reportTo: string;
      activeCutoffDate: string | undefined;
    }

    /**
     * Resolve the reporting period and ACTIVE recency cut-off from input params.
     */
    function resolveOperationContext(
      params: ReturnType<typeof MonitorLegislativePipelineSchema.parse>
    ): OperationContext {
      const toIsoDate = (d: Date): string => d.toISOString().slice(0, 10);
      const todayIso = toIsoDate(new Date());
      const defaultFromIso = ((): string => {
        const d = new Date();
        d.setUTCDate(d.getUTCDate() - 30);
        return toIsoDate(d);
      })();
      const reportFrom = params.dateFrom ?? defaultFromIso;
      const reportTo = params.dateTo ?? todayIso;
      const activeCutoffDate = ((): string | undefined => {
        if (params.status !== 'ACTIVE' || params.dateFrom !== undefined) return undefined;
        const referenceYear = parseInt(
          (params.dateTo ?? todayIso).slice(0, 4),
          10
        );
        return `${String(referenceYear - ACTIVE_RECENCY_YEARS)}-01-01`;
      })();
      return { params, reportFrom, reportTo, activeCutoffDate };
    }

    /**
     * Apply the recency + explicit date-range filters to the raw procedure list.
     */
    function applyDateFilters(
      procedures: readonly Procedure[],
      ctx: OperationContext,
    ): Procedure[] {
      return procedures.filter(proc => {
        const lastActivity = proc.dateLastActivity !== '' ? proc.dateLastActivity : proc.dateInitiated;
        const initiated = proc.dateInitiated !== '' ? proc.dateInitiated : undefined;
        if (ctx.activeCutoffDate !== undefined && !isWithinRecencyCutoff(lastActivity, initiated, ctx.activeCutoffDate)) {
          return false;
        }
        return matchesDateRange(lastActivity, initiated, ctx.params.dateFrom, ctx.params.dateTo);
      });
    }

    /**
     * Build per-procedure pipeline items and count event-fetch failures.
     *
     * Returns the forecast basis indexed by `procedureId` so downstream
     * filtering can correlate items to their basis without relying on
     * positional indices (which become misaligned after filtering).
     */
    function buildPipelineItems(
      filteredProcs: readonly Procedure[],
      eventsByProcedureId: ReadonlyMap<string, EPEvent[]>,
      model: LifecycleStatisticsModel,
    ): { items: PipelineItem[]; basisByProcedureId: Map<string, ForecastBasis>; eventFetchFailures: number } {
      const items: PipelineItem[] = [];
      const basisByProcedureId = new Map<string, ForecastBasis>();
      let eventFetchFailures = 0;
      for (const proc of filteredProcs) {
        const rawEvents = eventsByProcedureId.get(proc.id);
        if (rawEvents === undefined || rawEvents.length === 0) {
          eventFetchFailures++;
        }
        const sortedEvents = sortEventsChronologically(rawEvents ?? []);
        const { item, basis } = procedureToPipelineItem(proc, sortedEvents, model);
        items.push(item);
        basisByProcedureId.set(proc.id, basis);
      }
      return { items, basisByProcedureId, eventFetchFailures };
    }

    /**
     * Load the lifecycle statistics model **from cache only** — never rebuild on
     * the request path.
     *
     * The corpus build (`/procedures` + up to {@link CORPUS_SIZE}
     * `/procedures/{id}/events`) cannot reliably finish inside this tool's
     * {@link OPERATION_TIMEOUT_MS} envelope on a cold runner: even with the
     * cooperative `deadline`, in-flight EP API requests cannot be cancelled and
     * the budget's worth of token-bucket spend leaves the request's own
     * `/procedures` + `fetchEventsBounded` starved by the 100 req/min rate
     * limit. The visible symptom is the outer 30 s `withTimeout` firing and the
     * tool returning a {@link buildTimeoutResponse} envelope without
     * `pipeline`/`summary`.
     *
     * On a cache miss we therefore return {@link emptyLifecycleStatisticsModel}
     * synchronously and degrade per-procedure forecasts to `INSUFFICIENT_DATA`.
     * The corpus model is warmed out-of-band (e.g. by sibling tools or a
     * scheduled job) via {@link getLifecycleStatistics}; once cached, subsequent
     * requests pick it up immediately. This preserves the response shape on
     * cold integration runs while leaving the warm-path forecasts unchanged.
     */
    function loadCachedLifecycleModel(): LifecycleStatisticsModel {
      const cached = getCachedLifecycleStatistics();
      Eif (cached !== null) {
        return cached;
      }
      // Audit-friendly fallback so cold-runner runs are observable without
      // emitting a noisy error: per-procedure forecasts degrade to
      // `INSUFFICIENT_DATA` and the response envelope is unchanged.
      console.error(
        '[monitor_legislative_pipeline] Lifecycle cache miss — using empty model;'
        + ' per-procedure forecasts will degrade to INSUFFICIENT_DATA',
      );
      return emptyLifecycleStatisticsModel();
    }

    /**
     * Run the core pipeline-monitoring operation: load procedures + lifecycle
     * statistics, fetch per-procedure events, score each procedure, filter, and
     * assemble the response envelope.
     */
    async function runPipelineOperation(
      params: ReturnType<typeof MonitorLegislativePipelineSchema.parse>
    ): Promise<LegislativePipelineAnalysis> {
      const ctx = resolveOperationContext(params);
      // Load the lifecycle model FROM CACHE ONLY. The corpus rebuild is far too
      // expensive for the request path (see `loadCachedLifecycleModel` JSDoc);
      // cache misses degrade per-procedure forecasts to `INSUFFICIENT_DATA` but
      // keep the response envelope intact and free the rate-limit budget for
      // the request's own `/procedures` + `fetchEventsBounded` calls.
      const model = loadCachedLifecycleModel();
      const procedures = await epClient.getProcedures({ limit: params.limit });

      const filteredProcs = applyDateFilters(procedures.data, ctx);
      const eventsByProcedureId = await fetchEventsBounded(filteredProcs);
      const { items: allMappedItems, basisByProcedureId, eventFetchFailures } =
        buildPipelineItems(filteredProcs, eventsByProcedureId, model);

      const unknownEnrichmentCount = params.status === 'ACTIVE'
        ? allMappedItems.filter(item => item.currentStage === 'Unknown').length
        : 0;

      const allItems = allMappedItems
        .filter((item) => matchesStatusFilter(item, params.status))
        .filter((item) => matchesCommitteeFilter(item, params.committee));

      const pipeline = allItems.slice(0, params.limit);
      const visibleBases: ForecastBasis[] = pipeline
        .map((item) => basisByProcedureId.get(item.procedureId))
        .filter((b): b is ForecastBasis => b !== undefined);
      const envelopeBasis = aggregateForecastBasis(visibleBases);

      return buildAnalysis({
        reportFrom: ctx.reportFrom,
        reportTo: ctx.reportTo,
        params,
        pipeline,
        model,
        envelopeBasis,
        unknownEnrichmentCount,
        eventFetchFailures,
      });
    }

    /**
     * Handles the monitor_legislative_pipeline MCP tool request.
     *
     * Monitors the European Parliament's active legislative pipeline by fetching
     * real procedures and their authoritative event timelines from the EP API
     * (`/procedures` + `/procedures/{id}/events`) and computing lifecycle-driven
     * health metrics: percentile-based bottleneck detection, historical-median
     * completion forecasts, and stage-aware dwell statistics.
     *
     * @param args - Raw tool arguments, validated against {@link MonitorLegislativePipelineSchema}
     * @returns MCP tool result containing pipeline items with stage, lifecycle events,
     *   forecast basis, summary counts, detected bottlenecks (≥95th percentile dwell),
     *   pipeline health score, throughput rate, bottleneck index, and legislative momentum.
     * @throws - If `args` fails schema validation (e.g., missing required fields or invalid format)
     * - If the European Parliament API is unreachable for the primary procedure list
     *
     * @example
     * ```typescript
     * const result = await handleMonitorLegislativePipeline({
     *   status: 'ACTIVE',
     *   committee: 'ENVI',
     *   dateFrom: '2024-01-01',
     *   dateTo: '2024-12-31',
     *   limit: 20
     * });
     * // Returns pipeline health score, stalled/active/completed counts,
     * // bottleneck list, lifecycleEvents per procedure, and forecastBasis.
     * ```
     *
     * @security - Input is validated with Zod before any API call.
     * - Personal data in responses is minimised per GDPR Article 5(1)(c).
     * - Bounded concurrency (≤8 parallel) on the event fan-out limits API load.
     * - Lifecycle corpus is cached 30 min; only event types/dates are retained.
     * - All requests are rate-limited and audit-logged per ISMS Policy AU-002.
     * @since 0.8.0
     * @see {@link monitorLegislativePipelineToolMetadata} for MCP schema registration
     * @see {@link handleTrackLegislation} for individual procedure stage and timeline tracking
     */
    export async function handleMonitorLegislativePipeline(
      args: unknown
    ): Promise<ToolResult> {
      const params = MonitorLegislativePipelineSchema.parse(args);

      try {
        const analysis = await withTimeout(
          runPipelineOperation(params),
          OPERATION_TIMEOUT_MS,
          'monitor_legislative_pipeline operation timed out'
        );
        return { content: [{ type: 'text', text: JSON.stringify(analysis, null, 2) }] };
      } catch (error: unknown) {
        Iif (error instanceof TimeoutError) {
          // Return a *shape-stable* timeout envelope so downstream consumers can
          // still introspect `pipeline` / `summary` / `methodology` (matching the
          // happy-path schema) and surface the timeout via `dataQualityWarnings`
          // + LOW confidence rather than the generic `buildTimeoutResponse` which
          // drops the response shape entirely.
          return buildPipelineTimeoutResponse(params);
        }
        // Preserve the original error as `cause` for server-side diagnostics
        // (logs/audit) while keeping the client-visible message generic so we
        // don't leak upstream URLs, status text, or other internal details.
        throw new ToolError({
          toolName: 'monitor_legislative_pipeline',
          operation: 'runPipelineOperation',
          message: 'Failed to monitor legislative pipeline',
          cause: error,
          isRetryable: true,
        });
      }
    }

    /**
     * Build a {@link LegislativePipelineAnalysis}-shaped envelope when the outer
     * operation exceeded {@link OPERATION_TIMEOUT_MS}. Preserves the response
     * contract — `pipeline`, `summary`, `methodology` (with the `EP API` token
     * sibling tools/tests assert on) — while clearly marking the call as a
     * partial / unavailable result via {@link LegislativePipelineAnalysis.confidenceLevel}
     * `'LOW'`, an explicit `dataQualityWarnings` entry, and `forecastBasis:
     * 'INSUFFICIENT_DATA'`. Used to keep integration tests deterministic when
     * the EP API is slow/rate-limited and the cooperative deadline fires.
     *
     * @internal
     */
    function buildPipelineTimeoutResponse(
      params: ReturnType<typeof MonitorLegislativePipelineSchema.parse>
    ): ToolResult {
      const ctx = resolveOperationContext(params);
      const timeoutWarning =
        `monitor_legislative_pipeline timed out after ${String(OPERATION_TIMEOUT_MS)}ms `
        + '— EP API was slow or rate-limited. Retry with a narrower committee/date filter '
        + 'or smaller `limit`; the lifecycle corpus may also still be warming.';
      const analysis: LegislativePipelineAnalysis = {
        period: { from: ctx.reportFrom, to: ctx.reportTo },
        filter: {
          ...(params.committee !== undefined ? { committee: params.committee } : {}),
          status: params.status,
        },
        pipeline: [],
        summary: {
          totalProcedures: 0,
          activeCount: 0,
          stalledCount: 0,
          completedCount: 0,
          avgDaysInPipeline: 0,
        },
        bottlenecks: [],
        computedAttributes: {
          pipelineHealthScore: 0,
          throughputRate: 0,
          bottleneckIndex: 0,
          stalledProcedureRate: 0,
          estimatedClearanceTime: 0,
          legislativeMomentum: 'UNKNOWN',
        },
        confidenceLevel: 'LOW',
        dataFreshness: 'Timeout — EP API was unreachable within the configured window',
        sourceAttribution: 'European Parliament Open Data Portal - data.europarl.europa.eu',
        methodology: 'Lifecycle-driven pipeline analysis using EP API /procedures and '
          + '/procedures/{id}/events. Operation exceeded the configured timeout before any '
          + 'procedures could be scored — no analytical conclusions were produced. '
          + 'Retry with a narrower filter or smaller `limit`.',
        dataQualityWarnings: [timeoutWarning],
        forecastBasis: 'INSUFFICIENT_DATA',
        lifecycleCorpus: { corpusSize: 0, totalObservations: 0, computationTimeMs: 0 },
      };
      return { content: [{ type: 'text', text: JSON.stringify(analysis, null, 2) }] };
    }

    /**
     * Tool metadata for MCP registration
     */
    export const monitorLegislativePipelineToolMetadata = {
      name: 'monitor_legislative_pipeline',
      description: 'Monitor legislative pipeline status with lifecycle-driven bottleneck detection and timeline forecasting. Tracks procedures through their authoritative event sequence (REFERRAL → COM_VOTE → EP_ADOPTION → SIGNATURE / REJECTION). Returns pipeline health score, throughput rate, bottleneck index (procedures with dwell ≥ 95th percentile of historical distribution), stalled procedure rate, legislative momentum, per-procedure lifecycleEvents, and a forecastBasis discriminator (HISTORICAL_MEDIAN | INSUFFICIENT_DATA | NOT_APPLICABLE — the last when every visible procedure is already completed).',
      inputSchema: {
        type: 'object' as const,
        properties: {
          committee: {
            type: 'string',
            description: 'Filter by committee',
            minLength: 1,
            maxLength: 100
          },
          status: {
            type: 'string',
            enum: ['ALL', 'ACTIVE', 'STALLED', 'COMPLETED'],
            description: 'Pipeline status filter',
            default: 'ACTIVE'
          },
          dateFrom: {
            type: 'string',
            description: 'Analysis start date (YYYY-MM-DD format)',
            pattern: '^\\d{4}-\\d{2}-\\d{2}$'
          },
          dateTo: {
            type: 'string',
            description: 'Analysis end date (YYYY-MM-DD format)',
            pattern: '^\\d{4}-\\d{2}-\\d{2}$'
          },
          limit: {
            type: 'number',
            description: 'Maximum results to return (1-100)',
            minimum: 1,
            maximum: 100,
            default: 20
          }
        }
      }
    };