euparliamentmonitor 0.8.47 → 0.8.48

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euparliamentmonitor",
3
- "version": "0.8.47",
3
+ "version": "0.8.48",
4
4
  "type": "module",
5
5
  "description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
6
6
  "main": "scripts/index.js",
@@ -62,6 +62,7 @@
62
62
  "build:check-tests": "tsc --project tsconfig.test.json --noEmit",
63
63
  "copy-vendor": "node scripts/copy-vendor.js",
64
64
  "validate-analysis": "node scripts/validate-analysis-completeness.js",
65
+ "prior-run-diff": "node scripts/aggregator/prior-run-diff.js",
65
66
  "generate-article": "node scripts/aggregator/article-generator.js",
66
67
  "generate-article:all": "node scripts/aggregator/article-generator.js --all",
67
68
  "generate-news-indexes": "node scripts/generators/news-indexes.js",
@@ -165,6 +165,23 @@ export declare function deriveWeekRange(date: string): {
165
165
  readonly start: string;
166
166
  readonly end: string;
167
167
  };
168
+ /**
169
+ * Return the D-36 → D-8 reporting window for the `week-in-review`
170
+ * article type. EP roll-call voting data is published with a 2–6 week
171
+ * lag, so using the most-recent 7 days structurally produces a
172
+ * vote-empty dataset. Shifting 8 days back and widening to 28 days
173
+ * (start = D-36, end = D-8) ensures the window always contains at
174
+ * least one full EP plenary week with published roll-call data
175
+ * (ADR-006). Direction is consistent with the workflow's
176
+ * `DATE_FROM` (start = D-36) → `DATE_TO` (end = D-8) variables.
177
+ *
178
+ * @param date - ISO article date string (`YYYY-MM-DD`) — typically TODAY
179
+ * @returns `{ start: D-36, end: D-8 }` both as `YYYY-MM-DD` ISO strings
180
+ */
181
+ export declare function deriveReportingWindowForWeekInReview(date: string): {
182
+ readonly start: string;
183
+ readonly end: string;
184
+ };
168
185
  /**
169
186
  * Return a human-friendly month label for an ISO date — English month
170
187
  * name + four-digit year (e.g. `April 2026`). The non-English template
@@ -447,7 +447,12 @@ function safeReaddir(dir) {
447
447
  */
448
448
  export function buildTemplateFallback(articleType, date, committee) {
449
449
  const map = Object.create(null);
450
- const weekRange = deriveWeekRange(date);
450
+ // week-in-review uses the D-36→D-8 reporting window (ADR-006) so that
451
+ // EP roll-call voting data — published 2–6 weeks after the sitting —
452
+ // is always available in the analysis window.
453
+ const weekRange = articleType === 'week-in-review'
454
+ ? deriveReportingWindowForWeekInReview(date)
455
+ : deriveWeekRange(date);
451
456
  const monthLabel = deriveMonthLabel(date);
452
457
  const committeeLabel = committee && committee.trim().length > 0 ? committee : 'Main Committees';
453
458
  for (const lang of ALL_LANGUAGES) {
@@ -503,6 +508,8 @@ function templateForType(lang, articleType, inputs) {
503
508
  };
504
509
  }
505
510
  }
511
+ /** Milliseconds in one UTC day — used by date-window derivation helpers. */
512
+ const MS_PER_DAY = 86_400_000;
506
513
  /**
507
514
  * Parse an ISO date and return the `[start, end]` week range as ISO
508
515
  * strings. Week starts on Monday and ends on the following Sunday.
@@ -518,10 +525,32 @@ export function deriveWeekRange(date) {
518
525
  const day = parsed.getUTCDay();
519
526
  // Shift so Monday = 0, Sunday = 6.
520
527
  const shift = (day + 6) % 7;
521
- const startMs = parsed.getTime() - shift * 86_400_000;
522
- const endMs = startMs + 6 * 86_400_000;
528
+ const startMs = parsed.getTime() - shift * MS_PER_DAY;
529
+ const endMs = startMs + 6 * MS_PER_DAY;
523
530
  return { start: formatIsoDate(new Date(startMs)), end: formatIsoDate(new Date(endMs)) };
524
531
  }
532
+ /**
533
+ * Return the D-36 → D-8 reporting window for the `week-in-review`
534
+ * article type. EP roll-call voting data is published with a 2–6 week
535
+ * lag, so using the most-recent 7 days structurally produces a
536
+ * vote-empty dataset. Shifting 8 days back and widening to 28 days
537
+ * (start = D-36, end = D-8) ensures the window always contains at
538
+ * least one full EP plenary week with published roll-call data
539
+ * (ADR-006). Direction is consistent with the workflow's
540
+ * `DATE_FROM` (start = D-36) → `DATE_TO` (end = D-8) variables.
541
+ *
542
+ * @param date - ISO article date string (`YYYY-MM-DD`) — typically TODAY
543
+ * @returns `{ start: D-36, end: D-8 }` both as `YYYY-MM-DD` ISO strings
544
+ */
545
+ export function deriveReportingWindowForWeekInReview(date) {
546
+ const parsed = parseIsoDate(date);
547
+ if (!parsed)
548
+ return { start: date, end: date };
549
+ return {
550
+ start: formatIsoDate(new Date(parsed.getTime() - 36 * MS_PER_DAY)),
551
+ end: formatIsoDate(new Date(parsed.getTime() - 8 * MS_PER_DAY)),
552
+ };
553
+ }
525
554
  /**
526
555
  * Return a human-friendly month label for an ISO date — English month
527
556
  * name + four-digit year (e.g. `April 2026`). The non-English template
@@ -0,0 +1,506 @@
1
+ // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * @module Aggregator/ForwardStatementsRegistry
6
+ * @description Append-mostly JSONL registry for tracking forward-looking
7
+ * statements produced by week-ahead and month-ahead analysis runs. Each row
8
+ * records a single statement with its originating run, expected horizon, and
9
+ * current status. Subsequent week-ahead / month-ahead Stage A reads open items
10
+ * to seed synthesis.
11
+ *
12
+ * Schema per row:
13
+ * {
14
+ * id: string — UUID v4 (crypto.randomUUID())
15
+ * topic: string — Short topic slug (e.g. "banking-union", "ai-act")
16
+ * originatingRunId: string — RUN_ID from the originating workflow
17
+ * originatingDate: string — YYYY-MM-DD
18
+ * statement: string — The forward-looking statement text
19
+ * expectedHorizon: string — YYYY-MM-DD or ISO week (e.g. "2026-W18")
20
+ * status: "open" | "implemented" | "superseded" | "abandoned"
21
+ * lastObservedDate: string — YYYY-MM-DD of last run that touched this entry
22
+ * evidenceRefs: string[] — EP document IDs or procedure IDs supporting the claim
23
+ * }
24
+ *
25
+ * File layout:
26
+ * analysis/forward-statements/<YYYY-MM>.jsonl (one file per calendar month)
27
+ * analysis/forward-statements/README.md (schema + lifecycle docs — static)
28
+ *
29
+ * The monthly sharding keeps individual files small (typical plenary generates
30
+ * ~5–10 statements per week). Read operations scan every extant JSONL shard,
31
+ * with optional result filtering by status, horizonFrom, and horizonTo.
32
+ *
33
+ * Invocation:
34
+ * node scripts/aggregator/forward-statements-registry.js --help
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]
37
+ * node scripts/aggregator/forward-statements-registry.js update --id <id> --status <status> [--evidence <ref>] [--date <YYYY-MM-DD>]
38
+ * node scripts/aggregator/forward-statements-registry.js summary
39
+ */
40
+
41
+ import crypto from 'node:crypto';
42
+ import fs from 'node:fs';
43
+ import path from 'node:path';
44
+ import process from 'node:process';
45
+
46
+ const REGISTRY_DIR = path.resolve(process.cwd(), 'analysis/forward-statements');
47
+ const VALID_STATUSES = /** @type {const} */ (['open', 'implemented', 'superseded', 'abandoned']);
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Public helpers (exported for Vitest)
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Compute the JSONL shard path for a given YYYY-MM-DD date string.
55
+ *
56
+ * @param {string} dateStr - YYYY-MM-DD date string
57
+ * @param {string} [registryDir] - Override registry directory (used in tests)
58
+ * @returns {string} Absolute path to the shard file
59
+ */
60
+ export function shardPath(dateStr, registryDir) {
61
+ const month = dateStr.slice(0, 7); // "YYYY-MM"
62
+ return path.join(registryDir ?? REGISTRY_DIR, `${month}.jsonl`);
63
+ }
64
+
65
+ /**
66
+ * Parse a single JSONL line into a forward-statement entry. Returns `null`
67
+ * when the line is blank or cannot be parsed (corrupt shard lines are skipped
68
+ * rather than throwing so a single bad row does not crash the read operation).
69
+ *
70
+ * @param {string} line - Raw JSONL line
71
+ * @returns {Record<string, unknown> | null}
72
+ */
73
+ export function parseLine(line) {
74
+ const trimmed = line.trim();
75
+ if (!trimmed) return null;
76
+ try {
77
+ return JSON.parse(trimmed);
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Generate a new UUID v4 string using the Node.js `crypto` module.
85
+ *
86
+ * @returns {string} UUID v4
87
+ */
88
+ export function newId() {
89
+ return crypto.randomUUID();
90
+ }
91
+
92
+ /**
93
+ * Return true only when a string is a valid calendar date in YYYY-MM-DD form.
94
+ *
95
+ * @param {unknown} value - Candidate date value
96
+ * @returns {boolean} True for valid calendar dates
97
+ */
98
+ export function isValidDateString(value) {
99
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
100
+ const [year, month, day] = value.split('-').map(Number);
101
+ const parsed = new Date(Date.UTC(year, month - 1, day));
102
+ return (
103
+ parsed.getUTCFullYear() === year &&
104
+ parsed.getUTCMonth() === month - 1 &&
105
+ parsed.getUTCDate() === day
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Return true only when a string is a valid ISO week reference (`YYYY-Www`).
111
+ *
112
+ * @param {unknown} value - Candidate ISO week value
113
+ * @returns {boolean} True for ISO week 1–53
114
+ */
115
+ export function isValidIsoWeekString(value) {
116
+ if (typeof value !== 'string') return false;
117
+ const match = /^(\d{4})-W(\d{2})$/.exec(value);
118
+ if (!match) return false;
119
+ const week = Number(match[2]);
120
+ return week >= 1 && week <= 53;
121
+ }
122
+
123
+ /**
124
+ * Validate a candidate forward-statement entry object, returning a list of
125
+ * validation error strings. An empty array means the entry is valid.
126
+ *
127
+ * @param {unknown} entry - Candidate entry
128
+ * @returns {string[]} Validation errors (empty when valid)
129
+ */
130
+ export function validateEntry(entry) {
131
+ const errors = [];
132
+ if (!entry || typeof entry !== 'object') {
133
+ return ['Entry must be a non-null object'];
134
+ }
135
+ const e = /** @type {Record<string, unknown>} */ (entry);
136
+
137
+ if (typeof e.topic !== 'string' || !e.topic.trim()) errors.push('topic is required');
138
+ if (typeof e.originatingRunId !== 'string' || !e.originatingRunId.trim())
139
+ errors.push('originatingRunId is required');
140
+ if (!isValidDateString(e.originatingDate))
141
+ errors.push('originatingDate must be YYYY-MM-DD');
142
+ if (typeof e.statement !== 'string' || !e.statement.trim())
143
+ errors.push('statement is required');
144
+ if (
145
+ typeof e.expectedHorizon !== 'string' ||
146
+ (!isValidDateString(e.expectedHorizon) && !isValidIsoWeekString(e.expectedHorizon))
147
+ )
148
+ errors.push('expectedHorizon must be a valid YYYY-MM-DD date or ISO week YYYY-Www');
149
+ if (!VALID_STATUSES.includes(/** @type {never} */ (e.status)))
150
+ errors.push(`status must be one of: ${VALID_STATUSES.join(', ')}`);
151
+ if (!Array.isArray(e.evidenceRefs)) errors.push('evidenceRefs must be an array');
152
+
153
+ return errors;
154
+ }
155
+
156
+ /**
157
+ * Append one or more forward-statement entries to the appropriate monthly
158
+ * JSONL shards. Each entry receives a generated `id` and
159
+ * `lastObservedDate` (set to `originatingDate`) if not already present.
160
+ *
161
+ * @param {unknown[]} entries - Candidate entries to append
162
+ * @param {string} [registryDir] - Override registry directory (used in tests)
163
+ * @returns {{ written: number; errors: string[] }} Summary
164
+ */
165
+ export function appendEntries(entries, registryDir) {
166
+ const dir = registryDir ?? REGISTRY_DIR;
167
+ fs.mkdirSync(dir, { recursive: true });
168
+
169
+ let written = 0;
170
+ const errors = [];
171
+
172
+ for (const raw of entries) {
173
+ const entry = /** @type {Record<string, unknown>} */ (
174
+ raw && typeof raw === 'object' ? { .../** @type {object} */ (raw) } : {}
175
+ );
176
+
177
+ // Fill in generated fields
178
+ if (!entry.id || typeof entry.id !== 'string') entry.id = newId();
179
+ if (!entry.status) entry.status = 'open';
180
+ if (!Array.isArray(entry.evidenceRefs)) entry.evidenceRefs = [];
181
+ if (!entry.lastObservedDate) entry.lastObservedDate = entry.originatingDate;
182
+
183
+ const errs = validateEntry(entry);
184
+ if (errs.length > 0) {
185
+ errors.push(`Entry "${entry.topic ?? '(no topic)'}" invalid: ${errs.join('; ')}`);
186
+ continue;
187
+ }
188
+
189
+ const shard = shardPath(/** @type {string} */ (entry.originatingDate), dir);
190
+ fs.appendFileSync(shard, JSON.stringify(entry) + '\n', 'utf8');
191
+ written += 1;
192
+ }
193
+
194
+ return { written, errors };
195
+ }
196
+
197
+ /**
198
+ * Read all forward-statement entries from every JSONL shard, with optional
199
+ * filters.
200
+ *
201
+ * @param {object} [opts] - Filter options
202
+ * @param {string} [opts.status] - Filter by status (e.g. "open")
203
+ * @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
205
+ * @param {string} [opts.registryDir] - Override registry directory (used in tests)
206
+ * @returns {Record<string, unknown>[]} All matching entries
207
+ */
208
+ export function readEntries(opts) {
209
+ const dir = opts?.registryDir ?? REGISTRY_DIR;
210
+ if (!fs.existsSync(dir)) return [];
211
+
212
+ const shards = fs
213
+ .readdirSync(dir)
214
+ .filter((f) => f.endsWith('.jsonl'))
215
+ .sort();
216
+
217
+ /** @type {Map<string, Record<string, unknown>>} */
218
+ const entriesById = new Map();
219
+ for (const shard of shards) {
220
+ const content = fs.readFileSync(path.join(dir, shard), 'utf8');
221
+ for (const line of content.split('\n')) {
222
+ const entry = parseLine(line);
223
+ if (!entry) continue;
224
+ if (typeof entry.id !== 'string' || entry.id.length === 0) {
225
+ process.stderr.write(
226
+ `Skipping forward-statement entry with missing id in shard "${shard}"\n`,
227
+ );
228
+ continue;
229
+ }
230
+ entriesById.set(entry.id, entry);
231
+ }
232
+ }
233
+
234
+ const results = [];
235
+ const hasHorizonFilter = Boolean(opts?.horizonFrom || opts?.horizonTo);
236
+ for (const entry of entriesById.values()) {
237
+ if (opts?.status && entry.status !== opts.status) continue;
238
+
239
+ let horizon;
240
+ if (hasHorizonFilter) {
241
+ if (typeof entry.expectedHorizon !== 'string') {
242
+ process.stderr.write(
243
+ `Skipping forward-statement entry "${entry.id}" with missing expectedHorizon for horizon-filtered read\n`,
244
+ );
245
+ continue;
246
+ }
247
+
248
+ try {
249
+ // Normalise ISO week to YYYY-MM-DD (first day of week) for comparison.
250
+ horizon = normaliseHorizon(entry.expectedHorizon);
251
+ } catch (error) {
252
+ process.stderr.write(
253
+ `Skipping forward-statement entry "${entry.id}" with invalid expectedHorizon ` +
254
+ `"${entry.expectedHorizon}": ${error instanceof Error ? error.message : String(error)}\n`,
255
+ );
256
+ continue;
257
+ }
258
+ }
259
+
260
+ if (opts?.horizonFrom && typeof horizon === 'string' && horizon < opts.horizonFrom) continue;
261
+ if (opts?.horizonTo && typeof horizon === 'string' && horizon > opts.horizonTo) continue;
262
+ results.push(entry);
263
+ }
264
+ return results;
265
+ }
266
+
267
+ /**
268
+ * Update an existing entry's status, lastObservedDate, and optionally append
269
+ * an evidence reference. The update is written as a new JSONL line in the
270
+ * **current month's** shard (append-mostly pattern — old lines are kept for
271
+ * audit trail; readers use the last occurrence of each `id`).
272
+ *
273
+ * @param {object} opts - Update options
274
+ * @param {string} opts.id - ID of the entry to update
275
+ * @param {string} opts.status - New status
276
+ * @param {string} [opts.evidence] - Evidence reference to append
277
+ * @param {string} [opts.date] - lastObservedDate (defaults to today UTC)
278
+ * @param {string} [opts.registryDir] - Override registry directory (used in tests)
279
+ * @returns {{ updated: boolean; reason?: string }} Result
280
+ */
281
+ export function updateEntry(opts) {
282
+ const dir = opts?.registryDir ?? REGISTRY_DIR;
283
+
284
+ if (!VALID_STATUSES.includes(/** @type {never} */ (opts.status))) {
285
+ return { updated: false, reason: `Invalid status: ${opts.status}` };
286
+ }
287
+
288
+ // Find the latest version of the entry
289
+ const all = readEntries({ registryDir: dir });
290
+ // Build a map keyed by id — last occurrence wins (append-mostly semantics)
291
+ const byId = new Map();
292
+ for (const e of all) {
293
+ if (typeof e.id === 'string') byId.set(e.id, e);
294
+ }
295
+
296
+ const existing = byId.get(opts.id);
297
+ if (!existing) {
298
+ return { updated: false, reason: `No entry found with id=${opts.id}` };
299
+ }
300
+
301
+ const today = opts.date ?? new Date().toISOString().slice(0, 10);
302
+ const existingRefs = Array.isArray(existing.evidenceRefs) ? existing.evidenceRefs : [];
303
+ const updatedRefs = opts.evidence
304
+ ? Array.from(new Set([...existingRefs, opts.evidence]))
305
+ : [...existingRefs];
306
+ const updated = {
307
+ ...existing,
308
+ status: opts.status,
309
+ lastObservedDate: today,
310
+ evidenceRefs: updatedRefs,
311
+ };
312
+
313
+ fs.mkdirSync(dir, { recursive: true });
314
+ const shard = shardPath(today, dir);
315
+ fs.appendFileSync(shard, JSON.stringify(updated) + '\n', 'utf8');
316
+ return { updated: true };
317
+ }
318
+
319
+ /**
320
+ * Produce a human-readable summary string of all entries, grouped by status.
321
+ * Useful for Stage A manifests and `--summary` CLI.
322
+ *
323
+ * @param {string} [registryDir] - Override registry directory (used in tests)
324
+ * @returns {string} Formatted summary
325
+ */
326
+ export function buildSummary(registryDir) {
327
+ const all = readEntries({ registryDir });
328
+ const byStatus = {};
329
+ for (const status of VALID_STATUSES) {
330
+ byStatus[status] = all.filter((e) => e.status === status).length;
331
+ }
332
+
333
+ const lines = [
334
+ `## Forward-Statements Registry Summary`,
335
+ `Total entries: ${all.length}`,
336
+ ...VALID_STATUSES.map((s) => ` ${s}: ${byStatus[s]}`),
337
+ ];
338
+ return lines.join('\n');
339
+ }
340
+
341
+ /**
342
+ * Generate an array of EP plenary sitting IDs for consecutive session days.
343
+ * The format is `MTG-PL-YYYY-MM-DD` — the canonical sitting ID pattern used
344
+ * by the EP MCP `get_meeting_foreseen_activities` endpoint.
345
+ *
346
+ * @param {string} startDateStr - YYYY-MM-DD date of the first session day
347
+ * @param {number} numDays - Number of consecutive session days (typically 4 for Strasbourg, 2 for Brussels mini)
348
+ * @returns {string[]} Array of sitting IDs
349
+ */
350
+ export function generateSessionDayIds(startDateStr, numDays) {
351
+ const [year, month, day] = startDateStr.split('-').map(Number);
352
+ const startMs = Date.UTC(year, month - 1, day);
353
+ const ids = [];
354
+ for (let i = 0; i < numDays; i++) {
355
+ const ms = startMs + i * 24 * 60 * 60 * 1000;
356
+ const d = new Date(ms);
357
+ const y = d.getUTCFullYear();
358
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0');
359
+ const dd = String(d.getUTCDate()).padStart(2, '0');
360
+ ids.push(`MTG-PL-${y}-${m}-${dd}`);
361
+ }
362
+ return ids;
363
+ }
364
+
365
+ /**
366
+ * Return `true` when the given date string falls on a Monday (UTC).
367
+ * Used to determine whether the Monday urgency motion sweep should run.
368
+ *
369
+ * @param {string} dateStr - YYYY-MM-DD
370
+ * @returns {boolean}
371
+ */
372
+ export function isMondayRun(dateStr) {
373
+ const [y, m, d] = dateStr.split('-').map(Number);
374
+ return new Date(Date.UTC(y, m - 1, d)).getUTCDay() === 1;
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // Internal helpers
379
+ // ---------------------------------------------------------------------------
380
+
381
+ /**
382
+ * Normalise an ISO week string (`YYYY-Www`) to its Monday date as `YYYY-MM-DD`
383
+ * for lexicographic horizon comparisons. Valid `YYYY-MM-DD` dates pass through
384
+ * unchanged.
385
+ *
386
+ * @param {string} horizon - expectedHorizon value
387
+ * @returns {string} YYYY-MM-DD date string
388
+ * @throws {Error} When the horizon is not a valid `YYYY-MM-DD` date or ISO week
389
+ */
390
+ export function normaliseHorizon(horizon) {
391
+ if (isValidDateString(horizon)) return horizon;
392
+ if (/^\d{4}-\d{2}-\d{2}$/.test(horizon)) {
393
+ throw new Error(`Invalid calendar date in expectedHorizon: "${horizon}"`);
394
+ }
395
+
396
+ const isoWeek = /^(\d{4})-W(\d{2})$/.exec(horizon);
397
+ if (!isoWeek) {
398
+ throw new Error(`expectedHorizon must be YYYY-MM-DD or YYYY-Www: "${horizon}"`);
399
+ }
400
+ const year = Number(isoWeek[1]);
401
+ const week = Number(isoWeek[2]);
402
+ if (week < 1 || week > 53) {
403
+ throw new Error(`Invalid ISO week number: ${week} in "${horizon}" (must be 1–53)`);
404
+ }
405
+ // ISO week 1 starts on the Monday of the week containing Jan 4.
406
+ const jan4 = new Date(Date.UTC(year, 0, 4));
407
+ const monday = new Date(jan4);
408
+ // Move back to the Monday of the Jan-4 week, then forward by (week - 1) weeks.
409
+ monday.setUTCDate(jan4.getUTCDate() - ((jan4.getUTCDay() + 6) % 7) + (week - 1) * 7);
410
+ return monday.toISOString().slice(0, 10);
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // CLI entry point
415
+ // ---------------------------------------------------------------------------
416
+
417
+ /**
418
+ * Parse and dispatch CLI sub-commands.
419
+ *
420
+ * @param {string[]} argv - `process.argv` (slice from index 2 for args)
421
+ */
422
+ export function cli(argv) {
423
+ const [cmd, ...rest] = argv;
424
+
425
+ if (!cmd || cmd === '--help' || cmd === '-h') {
426
+ process.stdout.write(
427
+ [
428
+ 'Usage: node scripts/aggregator/forward-statements-registry.js <command> [options]',
429
+ '',
430
+ 'Commands:',
431
+ ' 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]',
434
+ ' Read and print matching entries as JSON array',
435
+ ' update --id <id> --status <status> [--evidence <ref>] [--date YYYY-MM-DD]',
436
+ ' Update an existing entry',
437
+ ' summary Print status counts',
438
+ '',
439
+ ].join('\n'),
440
+ );
441
+ process.exit(0);
442
+ }
443
+
444
+ if (cmd === 'append') {
445
+ const fileFlag = rest.indexOf('--file');
446
+ let rawJson;
447
+ if (fileFlag !== -1 && rest[fileFlag + 1]) {
448
+ rawJson = fs.readFileSync(rest[fileFlag + 1], 'utf8');
449
+ } else {
450
+ rawJson = fs.readFileSync(0, 'utf8');
451
+ }
452
+ const entries = JSON.parse(rawJson);
453
+ const result = appendEntries(Array.isArray(entries) ? entries : [entries]);
454
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
455
+ if (result.errors.length > 0) process.exit(1);
456
+ return;
457
+ }
458
+
459
+ if (cmd === 'read') {
460
+ const statusFlag = rest.indexOf('--status');
461
+ const fromFlag = rest.indexOf('--horizon-from');
462
+ const toFlag = rest.indexOf('--horizon-to');
463
+ const opts = {};
464
+ if (statusFlag !== -1 && rest[statusFlag + 1]) opts.status = rest[statusFlag + 1];
465
+ if (fromFlag !== -1 && rest[fromFlag + 1]) opts.horizonFrom = rest[fromFlag + 1];
466
+ if (toFlag !== -1 && rest[toFlag + 1]) opts.horizonTo = rest[toFlag + 1];
467
+ process.stdout.write(JSON.stringify(readEntries(opts), null, 2) + '\n');
468
+ return;
469
+ }
470
+
471
+ if (cmd === 'update') {
472
+ const parseFlag = (flag) => {
473
+ const idx = rest.indexOf(flag);
474
+ return idx !== -1 ? rest[idx + 1] : undefined;
475
+ };
476
+ const id = parseFlag('--id');
477
+ const status = parseFlag('--status');
478
+ const evidence = parseFlag('--evidence');
479
+ const date = parseFlag('--date');
480
+ if (!id || !status) {
481
+ process.stderr.write('update requires --id and --status\n');
482
+ process.exit(2);
483
+ }
484
+ const result = updateEntry({ id, status, evidence, date });
485
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
486
+ if (!result.updated) process.exit(1);
487
+ return;
488
+ }
489
+
490
+ if (cmd === 'summary') {
491
+ process.stdout.write(buildSummary() + '\n');
492
+ return;
493
+ }
494
+
495
+ process.stderr.write(`Unknown command: ${cmd}\n`);
496
+ process.exit(2);
497
+ }
498
+
499
+ // Run CLI when invoked directly
500
+ if (
501
+ process.argv[1] &&
502
+ (process.argv[1].endsWith('forward-statements-registry.js') ||
503
+ process.argv[1].endsWith('forward-statements-registry'))
504
+ ) {
505
+ cli(process.argv.slice(2));
506
+ }