euparliamentmonitor 0.8.52 โ†’ 0.8.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +14 -8
  2. package/package.json +4 -4
  3. package/scripts/aggregator/article-metadata.d.ts +33 -0
  4. package/scripts/aggregator/article-metadata.js +118 -1
  5. package/scripts/aggregator/artifact-order.js +21 -0
  6. package/scripts/aggregator/forward-statements-registry.js +69 -6
  7. package/scripts/aggregator/manifest/index.d.ts +2 -1
  8. package/scripts/aggregator/manifest/index.js +1 -0
  9. package/scripts/aggregator/manifest/manifest-writer.d.ts +50 -0
  10. package/scripts/aggregator/manifest/manifest-writer.js +89 -0
  11. package/scripts/aggregator/manifest/types.d.ts +29 -0
  12. package/scripts/aggregator/pipeline-transit-model.js +589 -0
  13. package/scripts/check-election-tier.js +105 -0
  14. package/scripts/config/article-horizons.d.ts +152 -0
  15. package/scripts/config/article-horizons.js +461 -0
  16. package/scripts/config/index.d.ts +6 -0
  17. package/scripts/config/index.js +8 -0
  18. package/scripts/constants/committee-indicator-map.js +148 -0
  19. package/scripts/constants/config.d.ts +25 -3
  20. package/scripts/constants/config.js +51 -2
  21. package/scripts/constants/language-articles.d.ts +12 -0
  22. package/scripts/constants/language-articles.js +354 -0
  23. package/scripts/constants/language-ui.d.ts +16 -0
  24. package/scripts/constants/language-ui.js +210 -14
  25. package/scripts/constants/languages.d.ts +1 -1
  26. package/scripts/constants/languages.js +1 -1
  27. package/scripts/generators/political-intelligence-descriptions.d.ts +1 -1
  28. package/scripts/generators/political-intelligence-descriptions.js +136 -0
  29. package/scripts/mcp/ep-mcp-client.d.ts +61 -5
  30. package/scripts/mcp/ep-mcp-client.js +72 -7
  31. package/scripts/templates/icons.d.ts +6 -1
  32. package/scripts/templates/icons.js +32 -1
  33. package/scripts/templates/section-builders.js +51 -39
  34. package/scripts/types/common.d.ts +18 -2
  35. package/scripts/types/common.js +25 -0
  36. package/scripts/utils/article-category.js +8 -0
  37. package/scripts/utils/file-utils.js +11 -0
  38. package/scripts/utils/news-metadata.js +5 -0
  39. package/scripts/validate-analysis-completeness.js +352 -16
package/README.md CHANGED
@@ -55,7 +55,7 @@ This repository is the open-source platform behind **[euparliamentmonitor.com](h
55
55
  | ๐Ÿง  **Political Intelligence** | Structured analytic techniques (ACH, SWOT/TOWS, PESTLE, scenario forecasting, devil's-advocate), Admiralty source grading, Words of Estimative Probability (WEP) bands, ICD-203 standards, and a 6-dimension political-threat framework โ€” applied daily to live EP data. |
56
56
  | ๐Ÿ” **Radical Transparency** | Every article links back to the analysis run it was rendered from. Every artifact links to its methodology. Every methodology is published. No black box. |
57
57
  | ๐Ÿ—ณ๏ธ **Democratic Accountability** | Public-data only. GDPR-clean. No personal profiling. Multi-language so a Finnish farmer, a Greek student, and a Polish journalist all get the same intelligence in their own language. |
58
- | ๐Ÿค– **AI-Generated News** | 8 unified gh-aw agentic workflows + 1 translation workflow run autonomously, produce structured analysis, render deterministic HTML, and open publication-ready pull requests for human review. |
58
+ | ๐Ÿค– **AI-Generated News** | 14 unified gh-aw agentic workflows + 1 translation workflow run autonomously, produce structured analysis, render deterministic HTML, and open publication-ready pull requests for human review. |
59
59
 
60
60
  ### Documentation & Reports
61
61
  [![API Docs](https://img.shields.io/badge/API-Documentation-blue?logo=javascript)](https://euparliamentmonitor.com/docs/api/)
@@ -124,7 +124,7 @@ The published site is the audience-facing companion to this npm/TypeScript packa
124
124
  - [Agent Catalog](.github/agents/README.md) โ€” custom Copilot agents (analysis producers / consumers / gh-aw infrastructure)
125
125
  - [Skills Library](.github/skills/README.md) โ€” shared skills (security, compliance, intelligence, gh-aw)
126
126
  - [Prompt Library](.github/prompts/README.md) โ€” 10-file bounded-context prompt set (`00`โ†’`09`) + `npm run lint:prompts` drift-guard
127
- - [Workflows](.github/workflows/README.md) + [WORKFLOWS.md](WORKFLOWS.md) โ€” 9 `news-*.md` agentic workflows (8 unified `news-<type>.md` + `news-translate.md`) + CI workflows
127
+ - [Workflows](.github/workflows/README.md) + [WORKFLOWS.md](WORKFLOWS.md) โ€” 15 `news-*.md` agentic workflows (14 unified `news-<type>.md` covering 14 article types โ€” including the long-horizon `quarter-ahead`/`quarter-in-review`/`year-ahead`/`year-in-review`/`term-outlook`/`election-cycle` set added in 2026-Q2 โ€” plus `news-translate.md`) + CI workflows
128
128
  - [Analysis Chain](analysis/README.md) โ€” 5-stage pipeline (Data โ†’ Analysis โ†’ Completeness Gate โ†’ Article โ†’ Single PR), methodologies, 39 templates, quality thresholds
129
129
 
130
130
  **๐Ÿ”’ ISMS Compliance:**
@@ -136,11 +136,11 @@ The published site is the audience-facing companion to this npm/TypeScript packa
136
136
 
137
137
  **MCP Server Integration**: The project uses the
138
138
  [European-Parliament-MCP-Server](https://github.com/Hack23/European-Parliament-MCP-Server)
139
- v1.2.18 for accessing real EU Parliament data via the Model Context Protocol.
139
+ v1.2.20 for accessing real EU Parliament data via the Model Context Protocol.
140
140
 
141
141
  - **MCP Server Status**: โœ… Fully operational โ€” 60+ EP data tools available
142
142
  (feeds, direct lookups, analytical tools, intelligence correlation)
143
- - **Agentic Workflows**: 9 unified gh-aw markdown workflows โ€” 8 article types (`news-<type>.md`, Stages A โ†’ B โ†’ C โ†’ D โ†’ E in one session) + `news-translate.md` (14-language flush translation) โ€” compiled with
143
+ - **Agentic Workflows**: 15 unified gh-aw markdown workflows โ€” 14 article types (`news-<type>.md`, Stages A โ†’ B โ†’ C โ†’ D โ†’ E in one session) + `news-translate.md` (14-language flush translation) โ€” compiled with
144
144
  `gh-aw v0.69.3` (pin in `.github/workflows/compile-agentic-workflows.yml`) to `.lock.yml` for automated news generation with AI-driven political
145
145
  intelligence analysis. See [`.github/workflows/README.md`](.github/workflows/README.md).
146
146
  - **Analysis-Artifact-Driven Article Pipeline**: Agents author the full
@@ -338,21 +338,27 @@ flowchart LR
338
338
 
339
339
  ## ๐Ÿ“ฐ Live News Streams
340
340
 
341
- **Eight unified gh-aw article workflows** plus a **14-language translation helper** run on precision schedules, autonomously generating *Economist-style* political intelligence and opening publication-ready pull requests. Every workflow follows the same Stage A โ†’ E contract documented in [Article-Generation.md](Article-Generation.md).
341
+ **Fourteen unified gh-aw article workflows** plus a **14-language translation helper** run on precision schedules, autonomously generating *Economist-style* political intelligence and opening publication-ready pull requests. Every workflow follows the same Stage A โ†’ E contract documented in [Article-Generation.md](Article-Generation.md). The single source of truth for every horizon's data window, cadence, mandatory artifacts, stage budgets, scenario depth and electoral overlay is the [`ARTICLE_HORIZONS` registry in `src/config/article-horizons.ts`](src/config/article-horizons.ts).
342
342
 
343
343
  | Workflow | Article Type | Focus |
344
344
  |----------|--------------|-------|
345
345
  | ๐Ÿšจ **[news-breaking.md](.github/workflows/news-breaking.md)** | `breaking` | Rapid-response coverage of significant EP developments. |
346
346
  | ๐Ÿ“‹ **[news-week-ahead.md](.github/workflows/news-week-ahead.md)** | `week-ahead` | Forward calendar, committee agenda, urgency-file outlook. |
347
347
  | ๐Ÿ”ญ **[news-month-ahead.md](.github/workflows/news-month-ahead.md)** | `month-ahead` | 30-day strategic horizon and risk forecast. |
348
+ | ๐ŸŒ **[news-quarter-ahead.md](.github/workflows/news-quarter-ahead.md)** | `quarter-ahead` | 90-day legislative pipeline forecast + presidency-trio overlay. |
349
+ | ๐Ÿ›ฐ๏ธ **[news-year-ahead.md](.github/workflows/news-year-ahead.md)** | `year-ahead` | 12-month strategic outlook with seat-projection sensitivity. |
350
+ | ๐Ÿ—“๏ธ **[news-term-outlook.md](.github/workflows/news-term-outlook.md)** | `term-outlook` | Full EP-term outlook anchored to the next-EP-election week. |
351
+ | ๐Ÿ—ณ๏ธ **[news-election-cycle.md](.github/workflows/news-election-cycle.md)** | `election-cycle` | EP-election span (ยฑ6 mo) โ€” mandate scorecard + seat projection + Spitzenkandidaten arithmetic. |
348
352
  | ๐Ÿ“Š **[news-week-in-review.md](.github/workflows/news-week-in-review.md)** | `week-in-review` | Past-week retrospective intelligence (D-8 โ†’ D-36 reporting window). |
349
353
  | ๐Ÿ“ˆ **[news-month-in-review.md](.github/workflows/news-month-in-review.md)** | `month-in-review` | Monthly retrospective with cross-run continuity analysis. |
354
+ | ๐Ÿ“š **[news-quarter-in-review.md](.github/workflows/news-quarter-in-review.md)** | `quarter-in-review` | Quarterly retrospective with pipeline transit + presidency-trio overlay. |
355
+ | ๐Ÿ“œ **[news-year-in-review.md](.github/workflows/news-year-in-review.md)** | `year-in-review` | Annual retrospective with mandate-fulfilment + term-arc + historical parallels. |
350
356
  | ๐Ÿ›๏ธ **[news-committee-reports.md](.github/workflows/news-committee-reports.md)** | `committee-reports` | Committee activity, rapporteur work, legislative-production analysis. |
351
357
  | โš–๏ธ **[news-motions.md](.github/workflows/news-motions.md)** | `motions` | Plenary motions, resolutions, urgency files, political signals. |
352
358
  | ๐Ÿ“œ **[news-propositions.md](.github/workflows/news-propositions.md)** | `propositions` | Legislative proposals and pipeline analysis. |
353
359
  | ๐ŸŒ **[news-translate.md](.github/workflows/news-translate.md)** | translation helper | 14-language flush translation (manual dispatch only). |
354
360
 
355
- Each unified workflow runs Stages Aโ€“E **in a single 45-minute session** and produces exactly one PR containing both the analysis artifacts and the rendered HTML. The earlier split-pair `news-<type>-analysis.md` + `news-<type>-article.md` layout was retired in the April-2026 aggregator migration. See **[.github/workflows/README.md](.github/workflows/README.md)** for compile / lock-file / safe-output mechanics, and **[WORKFLOWS.md](WORKFLOWS.md)** for the full CI/CD catalog.
361
+ Each unified workflow runs Stages Aโ€“E **in a single 60-minute session** (`engine.mcp.session-timeout: 65m`) and produces exactly one PR containing both the analysis artifacts and the rendered HTML. The earlier split-pair `news-<type>-analysis.md` + `news-<type>-article.md` layout was retired in the April-2026 aggregator migration. See **[.github/workflows/README.md](.github/workflows/README.md)** for compile / lock-file / safe-output mechanics, **[WORKFLOWS.md](WORKFLOWS.md)** for the full CI/CD catalog, and **[Article-Generation.md ยง Forward-looking horizons & election cycle](Article-Generation.md)** for the new long-horizon and electoral pipeline.
356
362
 
357
363
  > ๐Ÿ“š **Prompt Library (`.github/prompts/`)** โ€” 10 bounded-context prompt files (`00-scope-and-ground-rules.md` โ†’ `09-troubleshooting.md`) shared across every workflow. The `npm run lint:prompts` drift-guard fails CI on banned patterns (`checkpoint pr`, `keep-alive`, `heartbeat`, `progressive safe output`, `push_repo_memory`).
358
364
 
@@ -426,7 +432,7 @@ import type { ArticleCategory, LanguageCode } from 'euparliamentmonitor/types';
426
432
 
427
433
  ## ๐Ÿ”Œ Data Sources
428
434
 
429
- **Primary โ€” European Parliament MCP Server** ([Hack23/European-Parliament-MCP-Server](https://github.com/Hack23/European-Parliament-MCP-Server) v1.2.18+, fully operational):
435
+ **Primary โ€” European Parliament MCP Server** ([Hack23/European-Parliament-MCP-Server](https://github.com/Hack23/European-Parliament-MCP-Server) v1.2.20+, fully operational):
430
436
 
431
437
  - ๐Ÿ—ณ๏ธ Plenary sessions, voting records, roll-call votes
432
438
  - ๐Ÿ“œ Adopted texts, motions, resolutions, urgency files
@@ -560,7 +566,7 @@ Six-phase roadmap from current agentic news generation to AGI-enhanced transform
560
566
  timeline
561
567
  title EU Parliament Monitor โ€” AI Evolution Roadmap
562
568
  section Phase 1 (2026)
563
- Agentic News : 8 unified workflows
569
+ Agentic News : 14 unified workflows
564
570
  : 14-language generation
565
571
  : Deterministic aggregator
566
572
  : 51-artifact analysis catalog
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euparliamentmonitor",
3
- "version": "0.8.52",
3
+ "version": "0.8.54",
4
4
  "type": "module",
5
5
  "description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
6
6
  "main": "scripts/index.js",
@@ -131,7 +131,7 @@
131
131
  },
132
132
  "homepage": "https://euparliamentmonitor.com",
133
133
  "devDependencies": {
134
- "@axe-core/playwright": "4.11.2",
134
+ "@axe-core/playwright": "4.11.3",
135
135
  "@eslint/js": "10.0.1",
136
136
  "@playwright/test": "1.59.1",
137
137
  "@types/d3": "7.4.3",
@@ -145,7 +145,7 @@
145
145
  "chart.js": "4.5.1",
146
146
  "chartjs-plugin-annotation": "3.1.0",
147
147
  "d3": "7.9.0",
148
- "eslint": "10.2.1",
148
+ "eslint": "10.3.0",
149
149
  "eslint-config-prettier": "10.1.8",
150
150
  "eslint-plugin-jsdoc": "62.9.0",
151
151
  "eslint-plugin-security": "4.0.0",
@@ -169,7 +169,7 @@
169
169
  "node": ">=25"
170
170
  },
171
171
  "dependencies": {
172
- "european-parliament-mcp-server": "1.2.18",
172
+ "european-parliament-mcp-server": "1.2.20",
173
173
  "markdown-it": "^14.1.1",
174
174
  "markdown-it-anchor": "^9.2.0",
175
175
  "markdown-it-attrs": "^4.3.1",
@@ -192,6 +192,39 @@ export declare function deriveReportingWindowForWeekInReview(date: string): {
192
192
  * @returns Month label, or the input when parsing fails
193
193
  */
194
194
  export declare function deriveMonthLabel(date: string): string;
195
+ /**
196
+ * Return a quarter label for an ISO date โ€” `Q<n> <YYYY>` (e.g. `Q2 2026`).
197
+ * Used by `quarter-ahead` and `quarter-in-review` title generators.
198
+ *
199
+ * @param date - ISO date string
200
+ * @returns Quarter label, or the input when parsing fails
201
+ */
202
+ export declare function deriveQuarterLabel(date: string): string;
203
+ /**
204
+ * Return a four-digit year label for an ISO date. Used by `year-ahead`
205
+ * and `year-in-review` title generators.
206
+ *
207
+ * @param date - ISO date string
208
+ * @returns Year label, or the input when parsing fails
209
+ */
210
+ export declare function deriveYearLabel(date: string): string;
211
+ /**
212
+ * Return the EP-term label for an ISO date โ€” `EP10 โ†’ 2029` or `EP11 โ†’ 2034`.
213
+ * Used by `term-outlook` title generator.
214
+ *
215
+ * @param date - ISO date string
216
+ * @returns Term label, or the input when parsing fails
217
+ */
218
+ export declare function deriveTermLabel(date: string): string;
219
+ /**
220
+ * Return the election-cycle label for an ISO date โ€” pairs the outgoing
221
+ * and incoming EP terms with the election year (e.g. `EP10 โ†’ EP11 (2029)`).
222
+ * Used by the `election-cycle` title generator.
223
+ *
224
+ * @param date - ISO date string
225
+ * @returns Cycle label, or the input when parsing fails
226
+ */
227
+ export declare function deriveElectionCycleLabel(date: string): string;
195
228
  /**
196
229
  * Resolve per-language `{title, description}` for one article following
197
230
  * the priority ladder documented at the top of this module.
@@ -45,7 +45,7 @@
45
45
  import fs from 'fs';
46
46
  import path from 'path';
47
47
  import { ALL_LANGUAGES, getLocalizedString } from '../constants/language-core.js';
48
- import { BREAKING_NEWS_TITLES, COMMITTEE_REPORTS_TITLES, MONTH_AHEAD_TITLES, MONTHLY_REVIEW_TITLES, MOTIONS_TITLES, PROPOSITIONS_TITLES, WEEK_AHEAD_TITLES, WEEKLY_REVIEW_TITLES, } from '../constants/language-articles.js';
48
+ import { BREAKING_NEWS_TITLES, COMMITTEE_REPORTS_TITLES, ELECTION_CYCLE_TITLES, MONTH_AHEAD_TITLES, MONTHLY_REVIEW_TITLES, MOTIONS_TITLES, PROPOSITIONS_TITLES, QUARTER_AHEAD_TITLES, QUARTER_IN_REVIEW_TITLES, TERM_OUTLOOK_TITLES, WEEK_AHEAD_TITLES, WEEKLY_REVIEW_TITLES, YEAR_AHEAD_TITLES, YEAR_IN_REVIEW_TITLES, } from '../constants/language-articles.js';
49
49
  /** Maximum `<meta description>` length we will emit. */
50
50
  const DESCRIPTION_MAX_LENGTH = 300;
51
51
  /** Maximum `<title>` length โ€” anything longer is truncated with an ellipsis. */
@@ -455,6 +455,10 @@ export function buildTemplateFallback(articleType, date, committee) {
455
455
  : deriveWeekRange(date);
456
456
  const monthLabel = deriveMonthLabel(date);
457
457
  const committeeLabel = committee && committee.trim().length > 0 ? committee : 'Main Committees';
458
+ const quarterLabel = deriveQuarterLabel(date);
459
+ const yearLabel = deriveYearLabel(date);
460
+ const termLabel = deriveTermLabel(date);
461
+ const cycleLabel = deriveElectionCycleLabel(date);
458
462
  for (const lang of ALL_LANGUAGES) {
459
463
  const entry = templateForType(lang, articleType, {
460
464
  date,
@@ -462,6 +466,10 @@ export function buildTemplateFallback(articleType, date, committee) {
462
466
  weekEnd: weekRange.end,
463
467
  month: monthLabel,
464
468
  committee: committeeLabel,
469
+ quarter: quarterLabel,
470
+ year: yearLabel,
471
+ term: termLabel,
472
+ cycle: cycleLabel,
465
473
  });
466
474
  Object.defineProperty(map, lang, {
467
475
  value: entry,
@@ -501,6 +509,18 @@ function templateForType(lang, articleType, inputs) {
501
509
  return getLocalizedString(WEEKLY_REVIEW_TITLES, lang)(inputs.weekStart, inputs.weekEnd);
502
510
  case 'month-in-review':
503
511
  return getLocalizedString(MONTHLY_REVIEW_TITLES, lang)(inputs.month);
512
+ case 'quarter-ahead':
513
+ return getLocalizedString(QUARTER_AHEAD_TITLES, lang)(inputs.quarter);
514
+ case 'quarter-in-review':
515
+ return getLocalizedString(QUARTER_IN_REVIEW_TITLES, lang)(inputs.quarter);
516
+ case 'year-ahead':
517
+ return getLocalizedString(YEAR_AHEAD_TITLES, lang)(inputs.year);
518
+ case 'year-in-review':
519
+ return getLocalizedString(YEAR_IN_REVIEW_TITLES, lang)(inputs.year);
520
+ case 'term-outlook':
521
+ return getLocalizedString(TERM_OUTLOOK_TITLES, lang)(inputs.term);
522
+ case 'election-cycle':
523
+ return getLocalizedString(ELECTION_CYCLE_TITLES, lang)(inputs.cycle);
504
524
  default:
505
525
  return {
506
526
  title: `${humanizeSlug(articleType)} โ€” ${inputs.date}`,
@@ -581,6 +601,103 @@ export function deriveMonthLabel(date) {
581
601
  const name = monthNames[parsed.getUTCMonth()] ?? '';
582
602
  return `${name} ${parsed.getUTCFullYear()}`.trim();
583
603
  }
604
+ /**
605
+ * Return a quarter label for an ISO date โ€” `Q<n> <YYYY>` (e.g. `Q2 2026`).
606
+ * Used by `quarter-ahead` and `quarter-in-review` title generators.
607
+ *
608
+ * @param date - ISO date string
609
+ * @returns Quarter label, or the input when parsing fails
610
+ */
611
+ export function deriveQuarterLabel(date) {
612
+ const parsed = parseIsoDate(date);
613
+ if (!parsed)
614
+ return date;
615
+ const quarter = Math.floor(parsed.getUTCMonth() / 3) + 1;
616
+ return `Q${quarter} ${parsed.getUTCFullYear()}`;
617
+ }
618
+ /**
619
+ * Return a four-digit year label for an ISO date. Used by `year-ahead`
620
+ * and `year-in-review` title generators.
621
+ *
622
+ * @param date - ISO date string
623
+ * @returns Year label, or the input when parsing fails
624
+ */
625
+ export function deriveYearLabel(date) {
626
+ const parsed = parseIsoDate(date);
627
+ if (!parsed)
628
+ return date;
629
+ return String(parsed.getUTCFullYear());
630
+ }
631
+ /**
632
+ * EP-term constants โ€” keep these in sync with
633
+ * {@link analysis/methodologies/electoral-cycle-methodology.md}.
634
+ * - EP10: 16 Jul 2024 โ†’ ~end of June 2029
635
+ * - EP11: ~Jul 2029 โ†’ ~Jun 2034
636
+ */
637
+ const EP10_START_YEAR = 2024;
638
+ const EP10_END_YEAR = 2029;
639
+ const EP11_END_YEAR = 2034;
640
+ const EP_ELECTION_MONTH = 6; // June
641
+ /**
642
+ * Return the EP-term label for an ISO date โ€” `EP10 โ†’ 2029` or `EP11 โ†’ 2034`.
643
+ * Used by `term-outlook` title generator.
644
+ *
645
+ * @param date - ISO date string
646
+ * @returns Term label, or the input when parsing fails
647
+ */
648
+ export function deriveTermLabel(date) {
649
+ const parsed = parseIsoDate(date);
650
+ if (!parsed)
651
+ return date;
652
+ const year = parsed.getUTCFullYear();
653
+ const month = parsed.getUTCMonth() + 1;
654
+ if (year < EP10_START_YEAR)
655
+ return `EP9 โ†’ ${EP10_START_YEAR}`;
656
+ // EPn ends at end of June of its election year โ€” the constitutive sitting of
657
+ // EP(n+1) happens in early-mid July, so the term only flips after Jun.
658
+ if (year < EP10_END_YEAR || (year === EP10_END_YEAR && month <= EP_ELECTION_MONTH)) {
659
+ return `EP10 โ†’ ${EP10_END_YEAR}`;
660
+ }
661
+ if (year < EP11_END_YEAR || (year === EP11_END_YEAR && month <= EP_ELECTION_MONTH)) {
662
+ return `EP11 โ†’ ${EP11_END_YEAR}`;
663
+ }
664
+ // Beyond EP11 โ€” extrapolate by 5-year terms anchored at end of June of the
665
+ // election year. `termsBeyond=1` means EP12 (ends 2039), 2 means EP13, โ€ฆ
666
+ const yearsBeyond = year - EP11_END_YEAR;
667
+ const offset = month <= EP_ELECTION_MONTH ? 0 : 1;
668
+ const termsBeyond = Math.floor((yearsBeyond - 1 + offset) / 5) + 1;
669
+ const termIndex = 11 + termsBeyond;
670
+ const termEnd = EP11_END_YEAR + 5 * termsBeyond;
671
+ return `EP${termIndex} โ†’ ${termEnd}`;
672
+ }
673
+ /**
674
+ * Return the election-cycle label for an ISO date โ€” pairs the outgoing
675
+ * and incoming EP terms with the election year (e.g. `EP10 โ†’ EP11 (2029)`).
676
+ * Used by the `election-cycle` title generator.
677
+ *
678
+ * @param date - ISO date string
679
+ * @returns Cycle label, or the input when parsing fails
680
+ */
681
+ export function deriveElectionCycleLabel(date) {
682
+ const parsed = parseIsoDate(date);
683
+ if (!parsed)
684
+ return date;
685
+ const year = parsed.getUTCFullYear();
686
+ // The cycle "EPn โ†’ EP(n+1) (E)" labels the period **up to and including
687
+ // the entire calendar year of the election** โ€” i.e. ยฑ6 months around June
688
+ // of E. Pre-election dates anticipate E; post-election dates (e.g.
689
+ // 2029-12-01) still belong to the cycle that just resolved. The cycle
690
+ // flips on Jan 1 of the year after the election.
691
+ if (year <= EP10_END_YEAR)
692
+ return `EP10 โ†’ EP11 (${EP10_END_YEAR})`;
693
+ if (year <= EP11_END_YEAR)
694
+ return `EP11 โ†’ EP12 (${EP11_END_YEAR})`;
695
+ // Beyond EP11 โ€” extrapolate by 5-year cycles.
696
+ const cyclesBeyond = Math.ceil((year - EP11_END_YEAR) / 5);
697
+ const electionYear = EP11_END_YEAR + 5 * cyclesBeyond;
698
+ const out = 11 + cyclesBeyond;
699
+ return `EP${out} โ†’ EP${out + 1} (${electionYear})`;
700
+ }
584
701
  /**
585
702
  * Parse an ISO date string as UTC midnight. Returns `null` for malformed
586
703
  * input so callers can skip month/week derivation gracefully.
@@ -99,6 +99,27 @@ export const ARTIFACT_SECTIONS = [
99
99
  title: 'Scenarios & Wildcards',
100
100
  artifacts: ['intelligence/scenario-forecast.md', 'intelligence/wildcards-blackswans.md'],
101
101
  },
102
+ {
103
+ id: 'forward-projection',
104
+ title: 'Forward Projection',
105
+ artifacts: [
106
+ 'intelligence/forward-projection.md',
107
+ 'intelligence/legislative-pipeline-forecast.md',
108
+ 'intelligence/parliamentary-calendar-projection.md',
109
+ 'extended/forward-indicators.md',
110
+ ],
111
+ },
112
+ {
113
+ id: 'electoral-arc',
114
+ title: 'Electoral Arc & Mandate',
115
+ artifacts: [
116
+ 'intelligence/term-arc.md',
117
+ 'intelligence/seat-projection.md',
118
+ 'intelligence/mandate-fulfilment-scorecard.md',
119
+ 'intelligence/presidency-trio-context.md',
120
+ 'intelligence/commission-wp-alignment.md',
121
+ ],
122
+ },
102
123
  {
103
124
  id: 'continuity',
104
125
  title: 'Cross-Run Continuity',
@@ -17,7 +17,7 @@
17
17
  * originatingDate: string โ€” YYYY-MM-DD
18
18
  * statement: string โ€” The forward-looking statement text
19
19
  * expectedHorizon: string โ€” YYYY-MM-DD or ISO week (e.g. "2026-W18")
20
- * status: "open" | "implemented" | "superseded" | "abandoned"
20
+ * status: "open" | "implemented" | "superseded" | "abandoned" | "resolved" | "stale" | "extended"
21
21
  * lastObservedDate: string โ€” YYYY-MM-DD of last run that touched this entry
22
22
  * evidenceRefs: string[] โ€” EP document IDs or procedure IDs supporting the claim
23
23
  * }
@@ -33,7 +33,7 @@
33
33
  * Invocation:
34
34
  * node scripts/aggregator/forward-statements-registry.js --help
35
35
  * node scripts/aggregator/forward-statements-registry.js append <json-file-or-stdin>
36
- * node scripts/aggregator/forward-statements-registry.js read [--status open] [--horizon-from YYYY-MM-DD] [--horizon-to YYYY-MM-DD]
36
+ * node scripts/aggregator/forward-statements-registry.js read [--status open] [--horizon-from YYYY-MM-DD] [--horizon-to YYYY-MM-DD] [--electoral-mode]
37
37
  * node scripts/aggregator/forward-statements-registry.js update --id <id> --status <status> [--evidence <ref>] [--date <YYYY-MM-DD>]
38
38
  * node scripts/aggregator/forward-statements-registry.js summary
39
39
  */
@@ -44,7 +44,10 @@ import path from 'node:path';
44
44
  import process from 'node:process';
45
45
 
46
46
  const REGISTRY_DIR = path.resolve(process.cwd(), 'analysis/forward-statements');
47
- const VALID_STATUSES = /** @type {const} */ (['open', 'implemented', 'superseded', 'abandoned']);
47
+ const VALID_STATUSES = /** @type {const} */ ([
48
+ 'open', 'implemented', 'superseded', 'abandoned',
49
+ 'resolved', 'stale', 'extended',
50
+ ]);
48
51
 
49
52
  // ---------------------------------------------------------------------------
50
53
  // Public helpers (exported for Vitest)
@@ -201,7 +204,12 @@ export function appendEntries(entries, registryDir) {
201
204
  * @param {object} [opts] - Filter options
202
205
  * @param {string} [opts.status] - Filter by status (e.g. "open")
203
206
  * @param {string} [opts.horizonFrom] - ISO date; entries with expectedHorizon < this are excluded
204
- * @param {string} [opts.horizonTo] - ISO date; entries with expectedHorizon > this are excluded
207
+ * @param {string} [opts.horizonTo] - ISO date; entries with expectedHorizon > this are excluded.
208
+ * Long-horizon callers (term-outlook, election-cycle) may pass a date up to
209
+ * **+1825 days** (~5 years) ahead of `horizonFrom` to cover the EP-term arc.
210
+ * @param {boolean} [opts.electoralMode] - When true, only entries with `category === 'electoral'`
211
+ * (or whose `tags[]` contains `'electoral'`) are returned. Used by the `news-election-cycle.md`
212
+ * and `news-term-outlook.md` workflows to scope carry-forward to electoral-domain forecasts.
205
213
  * @param {string} [opts.registryDir] - Override registry directory (used in tests)
206
214
  * @returns {Record<string, unknown>[]} All matching entries
207
215
  */
@@ -259,11 +267,64 @@ export function readEntries(opts) {
259
267
 
260
268
  if (opts?.horizonFrom && typeof horizon === 'string' && horizon < opts.horizonFrom) continue;
261
269
  if (opts?.horizonTo && typeof horizon === 'string' && horizon > opts.horizonTo) continue;
270
+
271
+ if (opts?.electoralMode) {
272
+ const category = typeof entry.category === 'string' ? entry.category.toLowerCase() : '';
273
+ const tags = Array.isArray(entry.tags) ? entry.tags.map((t) => String(t).toLowerCase()) : [];
274
+ if (category !== 'electoral' && !tags.includes('electoral')) continue;
275
+ }
276
+
262
277
  results.push(entry);
263
278
  }
264
279
  return results;
265
280
  }
266
281
 
282
+ /** Statuses that close out an expired forward statement (all terminal states). */
283
+ const RESOLVED_STATUSES = [
284
+ 'implemented', 'superseded', 'abandoned',
285
+ 'resolved', 'stale', 'extended',
286
+ ];
287
+
288
+ /**
289
+ * Return forward-statement entries whose `expectedHorizon` is before `today`
290
+ * and whose latest status is NOT a terminal state (implemented, superseded,
291
+ * abandoned, resolved, stale, or extended).
292
+ * These are "expired unresolved" entries that Stage B must close out.
293
+ *
294
+ * @param {object} [opts] - Options
295
+ * @param {string} [opts.today] - Override today's date (YYYY-MM-DD); defaults to UTC today
296
+ * @param {string} [opts.registryDir] - Override registry directory (used in tests)
297
+ * @returns {Record<string, unknown>[]} Expired unresolved entries
298
+ */
299
+ export function readExpiredUnresolved(opts) {
300
+ const today = opts?.today ?? new Date().toISOString().slice(0, 10);
301
+ const all = readEntries({ registryDir: opts?.registryDir });
302
+ const expired = [];
303
+ for (const entry of all) {
304
+ if (typeof entry.expectedHorizon !== 'string') {
305
+ process.stderr.write(
306
+ `Skipping forward-statement entry "${entry.id}" with missing expectedHorizon\n`,
307
+ );
308
+ continue;
309
+ }
310
+ let horizon;
311
+ try {
312
+ horizon = normaliseHorizon(/** @type {string} */ (entry.expectedHorizon));
313
+ } catch (error) {
314
+ process.stderr.write(
315
+ `Skipping forward-statement entry "${entry.id}" with invalid expectedHorizon ` +
316
+ `"${entry.expectedHorizon}": ${error instanceof Error ? error.message : String(error)}\n`,
317
+ );
318
+ continue;
319
+ }
320
+ if (horizon >= today) continue;
321
+ const status = typeof entry.status === 'string' ? entry.status : '';
322
+ if (RESOLVED_STATUSES.includes(status)) continue;
323
+ expired.push(entry);
324
+ }
325
+ return expired;
326
+ }
327
+
267
328
  /**
268
329
  * Update an existing entry's status, lastObservedDate, and optionally append
269
330
  * an evidence reference. The update is written as a new JSONL line in the
@@ -429,8 +490,8 @@ export function cli(argv) {
429
490
  '',
430
491
  'Commands:',
431
492
  ' append [--file <path>] Append entries from a JSON array file (or stdin)',
432
- ' read [--status open|implemented|superseded|abandoned]',
433
- ' [--horizon-from YYYY-MM-DD] [--horizon-to YYYY-MM-DD]',
493
+ ' read [--status open|implemented|superseded|abandoned|resolved|stale|extended]',
494
+ ' [--horizon-from YYYY-MM-DD] [--horizon-to YYYY-MM-DD] [--electoral-mode]',
434
495
  ' Read and print matching entries as JSON array',
435
496
  ' update --id <id> --status <status> [--evidence <ref>] [--date YYYY-MM-DD]',
436
497
  ' Update an existing entry',
@@ -460,10 +521,12 @@ export function cli(argv) {
460
521
  const statusFlag = rest.indexOf('--status');
461
522
  const fromFlag = rest.indexOf('--horizon-from');
462
523
  const toFlag = rest.indexOf('--horizon-to');
524
+ const electoralFlag = rest.indexOf('--electoral-mode');
463
525
  const opts = {};
464
526
  if (statusFlag !== -1 && rest[statusFlag + 1]) opts.status = rest[statusFlag + 1];
465
527
  if (fromFlag !== -1 && rest[fromFlag + 1]) opts.horizonFrom = rest[fromFlag + 1];
466
528
  if (toFlag !== -1 && rest[toFlag + 1]) opts.horizonTo = rest[toFlag + 1];
529
+ if (electoralFlag !== -1) opts.electoralMode = true;
467
530
  process.stdout.write(JSON.stringify(readEntries(opts), null, 2) + '\n');
468
531
  return;
469
532
  }
@@ -2,7 +2,8 @@
2
2
  * @module Aggregator/Manifest
3
3
  * @description Public re-exports for the manifest bounded context.
4
4
  */
5
- export type { Manifest, ManifestFiles, ManifestHistoryEntry, ManifestMetadataOverride, MetadataManifest, } from './types.js';
5
+ export type { HorizonProfile, Manifest, ManifestFiles, ManifestHistoryEntry, ManifestMetadataOverride, MetadataManifest, } from './types.js';
6
6
  export { resolveArticleType, resolveDate, resolveRunId, latestGateResult, flattenManifestFiles, UNKNOWN_ARTICLE_TYPE, } from './resolver.js';
7
7
  export { readManifest, parseManifest, type ReadManifestResult } from './reader.js';
8
+ export { applyHorizonProfile, buildHorizonProfile } from './manifest-writer.js';
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -2,4 +2,5 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  export { resolveArticleType, resolveDate, resolveRunId, latestGateResult, flattenManifestFiles, UNKNOWN_ARTICLE_TYPE, } from './resolver.js';
4
4
  export { readManifest, parseManifest } from './reader.js';
5
+ export { applyHorizonProfile, buildHorizonProfile } from './manifest-writer.js';
5
6
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,50 @@
1
+ import type { HorizonProfile, Manifest } from './types.js';
2
+ /**
3
+ * Build a {@link HorizonProfile} for the given article-type slug from the
4
+ * canonical {@link import('../../config/article-horizons.js').ARTICLE_HORIZONS}
5
+ * registry.
6
+ *
7
+ * `horizonDays` derivation:
8
+ * - `forward` / `backward` โ†’ `dataWindow.days`
9
+ * - `span` / `point` โ†’ `forwardStatementsHorizonDays` (covers
10
+ * `election-cycle` โ†’ 1825, `breaking` โ†’ 0, etc.)
11
+ * - When `dataWindow.days` is absent (e.g. `point`) the
12
+ * `forwardStatementsHorizonDays` fallback applies regardless of direction.
13
+ *
14
+ * @param articleType - Article-type slug (e.g. `month-ahead`,
15
+ * `election-cycle`). Legacy / unknown slugs return
16
+ * `undefined` so the manifest writer treats them as
17
+ * no-ops.
18
+ * @returns The matching {@link HorizonProfile}, or `undefined` when the
19
+ * slug does not resolve to a registry entry.
20
+ */
21
+ export declare function buildHorizonProfile(articleType: string | undefined): HorizonProfile | undefined;
22
+ /**
23
+ * Return a copy of the manifest with `horizonProfile` populated from the
24
+ * article-horizons registry.
25
+ *
26
+ * Behaviour matrix:
27
+ * - Slug resolves to a registry entry โ†’ `horizonProfile` is set from
28
+ * {@link buildHorizonProfile}.
29
+ * - Slug is legacy / unknown (no registry entry) AND `overwrite` is
30
+ * `true` โ†’ any existing `horizonProfile` is **stripped** so the
31
+ * "absent for unknown slugs" invariant holds even when the registry
32
+ * evolves (e.g. a slug is removed) or a manifest carries a stale
33
+ * value from a previous registry version.
34
+ * - Slug is legacy / unknown AND `overwrite` is `false` โ†’ no-op.
35
+ * - An existing `horizonProfile` is present AND `overwrite` is `false`
36
+ * โ†’ no-op (forward-compat: respect a manifest-supplied value).
37
+ *
38
+ * The function is pure โ€” the input manifest is never mutated.
39
+ *
40
+ * @param manifest - Manifest to enrich.
41
+ * @param options - Behaviour options.
42
+ * @param options.overwrite - When `true`, replaces (or strips) any
43
+ * existing `horizonProfile`. Default `false`.
44
+ * @returns A new manifest with `horizonProfile` set or removed, or the
45
+ * original manifest reference when no change applies.
46
+ */
47
+ export declare function applyHorizonProfile(manifest: Manifest, options?: {
48
+ readonly overwrite?: boolean;
49
+ }): Manifest;
50
+ //# sourceMappingURL=manifest-writer.d.ts.map
@@ -0,0 +1,89 @@
1
+ // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * @module Aggregator/Manifest/Writer
5
+ * @description Helpers that mutate or enrich an in-memory {@link Manifest}
6
+ * before it is serialised to `manifest.json`. The current writer surface is
7
+ * intentionally narrow โ€” it only owns the {@link HorizonProfile} bucket
8
+ * derived from the canonical article-horizons registry. Workflows still
9
+ * write the rest of the manifest (articleType, runId, history) directly
10
+ * through `mergeManifestHistory` and equivalent shell helpers.
11
+ */
12
+ import { getHorizonConfig } from '../../config/article-horizons.js';
13
+ import { resolveArticleType } from './resolver.js';
14
+ /**
15
+ * Build a {@link HorizonProfile} for the given article-type slug from the
16
+ * canonical {@link import('../../config/article-horizons.js').ARTICLE_HORIZONS}
17
+ * registry.
18
+ *
19
+ * `horizonDays` derivation:
20
+ * - `forward` / `backward` โ†’ `dataWindow.days`
21
+ * - `span` / `point` โ†’ `forwardStatementsHorizonDays` (covers
22
+ * `election-cycle` โ†’ 1825, `breaking` โ†’ 0, etc.)
23
+ * - When `dataWindow.days` is absent (e.g. `point`) the
24
+ * `forwardStatementsHorizonDays` fallback applies regardless of direction.
25
+ *
26
+ * @param articleType - Article-type slug (e.g. `month-ahead`,
27
+ * `election-cycle`). Legacy / unknown slugs return
28
+ * `undefined` so the manifest writer treats them as
29
+ * no-ops.
30
+ * @returns The matching {@link HorizonProfile}, or `undefined` when the
31
+ * slug does not resolve to a registry entry.
32
+ */
33
+ export function buildHorizonProfile(articleType) {
34
+ if (!articleType)
35
+ return undefined;
36
+ const cfg = getHorizonConfig(articleType);
37
+ if (!cfg)
38
+ return undefined;
39
+ const { direction, days } = cfg.dataWindow;
40
+ const useFallback = direction === 'span' || direction === 'point' || days === undefined;
41
+ const horizonDays = useFallback ? cfg.forwardStatementsHorizonDays : days;
42
+ return Object.freeze({
43
+ horizonDays,
44
+ electoralOverlay: cfg.electoralOverlay,
45
+ });
46
+ }
47
+ /**
48
+ * Return a copy of the manifest with `horizonProfile` populated from the
49
+ * article-horizons registry.
50
+ *
51
+ * Behaviour matrix:
52
+ * - Slug resolves to a registry entry โ†’ `horizonProfile` is set from
53
+ * {@link buildHorizonProfile}.
54
+ * - Slug is legacy / unknown (no registry entry) AND `overwrite` is
55
+ * `true` โ†’ any existing `horizonProfile` is **stripped** so the
56
+ * "absent for unknown slugs" invariant holds even when the registry
57
+ * evolves (e.g. a slug is removed) or a manifest carries a stale
58
+ * value from a previous registry version.
59
+ * - Slug is legacy / unknown AND `overwrite` is `false` โ†’ no-op.
60
+ * - An existing `horizonProfile` is present AND `overwrite` is `false`
61
+ * โ†’ no-op (forward-compat: respect a manifest-supplied value).
62
+ *
63
+ * The function is pure โ€” the input manifest is never mutated.
64
+ *
65
+ * @param manifest - Manifest to enrich.
66
+ * @param options - Behaviour options.
67
+ * @param options.overwrite - When `true`, replaces (or strips) any
68
+ * existing `horizonProfile`. Default `false`.
69
+ * @returns A new manifest with `horizonProfile` set or removed, or the
70
+ * original manifest reference when no change applies.
71
+ */
72
+ export function applyHorizonProfile(manifest, options = {}) {
73
+ if (manifest.horizonProfile && !options.overwrite)
74
+ return manifest;
75
+ const articleType = resolveArticleType(manifest);
76
+ const profile = buildHorizonProfile(articleType);
77
+ if (!profile) {
78
+ // Slug is legacy / unknown. With overwrite=true we must actively
79
+ // strip any stale `horizonProfile` to honour the documented
80
+ // "absent for unknown slugs" invariant.
81
+ if (options.overwrite && manifest.horizonProfile) {
82
+ const { horizonProfile: _stale, ...rest } = manifest;
83
+ return rest;
84
+ }
85
+ return manifest;
86
+ }
87
+ return { ...manifest, horizonProfile: profile };
88
+ }
89
+ //# sourceMappingURL=manifest-writer.js.map