diff --git a/submissions/regulation-monitor/README.md b/submissions/regulation-monitor/README.md new file mode 100644 index 00000000..f4049cd1 --- /dev/null +++ b/submissions/regulation-monitor/README.md @@ -0,0 +1,128 @@ +# Regulation Monitor + +Configure this skill once for a regulation you care about, then let it run on a +schedule and drop a fresh dashboard into your inbox. + +## What it does + +Turns a saved **watch profile** — topics, jurisdictions, source list, cadence — +into a repeatable regulatory sweep. On each run the skill visits only the +sources you locked into the profile at setup: the top authoritative sources +the skill proposed for each topic, plus any seed URLs you added on top. It +classifies each new item (topic, jurisdiction, stage, date), flags items that +match your team's function-area keywords using a light WorkIQ-derived profile, +and produces a self-contained HTML dashboard. + +It is a **monitoring** tool. It reports what regulators, legislatures, and +courts are doing. It never files, calculates liability, or gives a legal +opinion. + +## When to use it + +- You want a recurring digest of regulatory changes in a defined area (tax, + privacy, AI, healthcare, ESG, labor, competition, anything domain-specific). +- You already know roughly what to watch (topic + jurisdictions) and want a + bounded, predictable sweep rather than an open-ended search every week. +- You want the same view for the same profile every week so you can diff week + over week. + +## When not to use it + +- One-off legal or regulatory research → use a research skill. +- Reading a single document you already have → use `docx` / `pptx`. +- Computing a compliance liability or filing position → out of scope. Escalate + to a human. + +## Setup walkthrough + +First time you invoke the skill for a new regulation, it walks you through a +short interactive setup: + +1. **Profile name**, e.g. `pillar-ii`, `eu-ai-act`, `hipaa-sec-2`. +2. **Watch topics** — 2 to 8 topics, each with a display name and a handful + of keywords the sweep will look for. +3. **Jurisdictions** — countries, regions, sectors, or `global`. +4. **Cadence** — daily, weekly (default), biweekly, monthly. +5. **Window** — days to look back per run (default matches cadence). +6. **Delivery target** — your own email (default), a Teams chat, a Loop page, + or inline only. + +The skill then does a small **discovery pass** to propose the top 5 +authoritative sources per topic (regulator page → official journal → +multilateral body → reputable tracker → firm public alert). It **stops for +your confirmation** — you can swap any of the 5, lower the target, or add +your own seed URLs — before starting any monitoring. + +After you confirm, the skill captures a light org profile from WorkIQ +(`workiq_get_my_profile`, `workiq_get_my_manager`, `workiq_get_relevant_people`) +and proposes 5–15 function-area keywords for the team-relevance flag. You edit +and confirm those too. + +Everything is written to `config.json` next to the dashboard output. Every +subsequent run reads it and visits only the confirmed source list. + +## Scheduling + +The core skill has no schedule of its own — it just runs. Wire it to your +platform's scheduler: + +### Scout + +Create a Scout automation: + +``` +name: Regulation Monitor — +schedule: every Monday at 8am +prompt: | + Run the regulation-monitor skill for profile "". + Load the profile's config.json, sweep the window since the last run, + build the dashboard, and email the digest to the user per the + delivery block. Always send the dashboard, including on quiet weeks — + topics with no items are reported explicitly as "No significant + developments this period" and the empty-state view is intentional. +teamsNotify: auto +``` + +Scout keeps the recurrence and invokes the skill. + +### Cowork + +Create a recurring Cowork task with the same prompt above, task name +`Regulation Monitor — `, recurrence matching the profile's +cadence. + +### What the schedule should not do + +- **Do not reconfigure the profile from the schedule.** Setup is an interactive + step. If the profile is missing or stale, the scheduled run should send a + short heads-up and stop, not silently rebuild. +- **Do not add external email recipients from the schedule.** The pre-authorized + recipient is you (the user). Any other recipient requires an interactive + confirmation. +- **Do not chain the monitor into downstream action.** This skill monitors; it + does not take a filing position or trigger a workflow. Keep the scheduled + job single-purpose. + +## Output + +- **Dashboard**: `output/regulation-dashboard.html` — KPI tiles, + client-side sortable color-coded table (click any column header to sort + by date, topic, jurisdiction, stage, title, or source), team-relevant + badges, per-row source links (URL scheme sanitized — only + `http`/`https`/`mailto` are rendered), and a "Quiet this period" section + listing every watch topic that produced zero items as + **"No significant developments this period"**. +- **Items JSON**: `working/regulation-items.json` — raw items from this run. +- **Inline summary**: ≤12 lines — window, item counts by topic, top team- + relevant items. Quiet topics are reported explicitly, not padded. + +## Tips + +- Keep watch topics narrow. "Pillar Two" as one topic gives you a coherent + weekly digest. "International tax" gives you noise. +- The domain-agnostic reputable-source allowlist for fallback search lives in + `references/sources-and-taxonomy.md`. Extend it via + `domain_allowlist_extensions` in the config if your domain needs sources + outside the defaults. +- Tune `runtime_budget` in the config if you find the sweep is too aggressive + (lower `max_items`) or too silent (raise `max_fallback_searches`). diff --git a/submissions/regulation-monitor/SKILL.md b/submissions/regulation-monitor/SKILL.md new file mode 100644 index 00000000..2b622ae4 --- /dev/null +++ b/submissions/regulation-monitor/SKILL.md @@ -0,0 +1,335 @@ +--- +name: regulation-monitor +description: Use when the user asks to set up, run, or check a scheduled regulation, law, or regulatory-guidance monitor or tracker — triggers include "set up a regulation tracker for X", "monitor [regulation] for me", "run my regulation monitor", "what changed on [regulation] this week", or an unattended scheduler invoking a saved profile. On first invocation walk interactive setup (topics, jurisdictions, cadence, delivery), auto-discover the top authoritative sources per topic, and STOP for user confirmation before any monitoring. On subsequent runs visit only the locked source list plus user seeds, classify items, flag team-relevant ones via a WorkIQ-derived keyword match, and render a self-contained HTML dashboard with client-side sortable columns. Do NOT use for one-off legal research, reading a single document, computing compliance liability or filing position, or non-regulatory news monitoring. +--- + +# Regulation Monitor + +## Source discipline + +This skill is deliberately **bounded and confirmed**. It does not run +open-ended web searches every run. Instead, at setup: + +1. Run a small **discovery pass** to identify the top 5 authoritative + sources per watch topic (regulators, official trackers, reputable + trade press). +2. **Present the shortlist to the user and stop.** No monitoring runs + until the user has confirmed the list. +3. The user can swap/remove any of the 5, lower the target, and supply + their own **seed sources** on top. +4. The confirmed list is **locked into the profile config**. + +Every subsequent run visits **only** those sources. A tightly-bounded +fallback web search (at most one query per topic, capped result count, +allowlist-filtered) is used only when a locked source is silent for a +topic in the window. + +## Instructions + +### Step 0 — First-run setup (only if no config exists) + +If `config.json` for the requested profile does not exist, walk the user +through setup: + +1. **Profile name** — kebab-case slug, e.g. `pillar-ii`. +2. **Watch topics** — 2 to 8 topics. For each: display name plus 3–8 + keywords the sweeps should look for. +3. **Jurisdictions** — countries, regions, states, sectors, or `global`. +4. **Cadence** — daily, weekly (default), biweekly, monthly. +5. **Window** — days to look back per run (default: matches cadence). +6. **Delivery target** — user's own email (default), a Teams chat, a Loop + page, or "inline only". + +### Step 1 — Auto-discover authoritative sources, then STOP for confirmation + +**This is an interactive checkpoint. Run a small discovery pass to +identify candidate sources, then present them and stop. Do not start +the monitoring sweep (Step 5) until the user has explicitly confirmed +the source list.** + +For each watch topic the user configured, propose up to **5 authoritative +sources** (default target 5; use fewer if the user asks or if the domain +has fewer canonical sources). + +Preference order (pick the strongest 5 that exist for the topic): + +1. The primary regulator / issuing body's official page for the topic. + Examples: OECD's Pillar Two page, the European Commission's page for + the AI Act, HHS OCR for HIPAA, EDPB for GDPR, ISSB for sustainability + disclosure. +2. Government official journals and legislative trackers for the + jurisdictions in scope. Examples: Federal Register, EUR-Lex, UK + legislation.gov.uk, state legislature bill pages. +3. The relevant multilateral, standard-setting, or specialist body's + page for the topic. Examples: OECD, UN, BIS, ISO, NIST (AI RMF, + cyber), WHO/EMA (health), ILO (labor), FSB (financial stability). +4. A reputable public tracker or think tank whose focus matches the + domain. Examples: Tax Foundation and MTC/NCSL for tax; IAPP and + Future of Privacy Forum for privacy; Stanford HAI, Brookings AI, and + the Ada Lovelace Institute for AI; ISSB and EFRAG for sustainability; + SHRM and EPI for labor; KFF for healthcare policy. +5. Public alert pages from major professional-services or specialist + firms that cover the domain (public URLs only, never subscriber + content). Examples: KPMG / EY / PwC / Deloitte / BDO insight pages + for tax and financial regulation; DLA Piper, Hogan Lovells, Wilson + Sonsini, Cooley for tech / privacy / AI; Ropes & Gray for healthcare; + Littler and Ogletree Deakins for labor. + +Match the mix to the domain — do not force tax-style sources onto a +non-tax topic. + +**Trackers and firm alerts are pointers, not authoritative sources.** +Sources in categories 4 and 5 are useful as indices to find primary +regulator/court/legislative material, but any item you record must cite +the underlying **official document or announcement** (regulator page, +official journal, court opinion) as its `source_url` — not the tracker +or firm alert that linked to it. If you only have the tracker link and +can't find the primary source, drop the item. + +**How to find them:** for each topic, do a short bounded discovery +pass — one to three focused web searches against the reputable-domain +allowlist in `references/sources-and-taxonomy.md` — just enough to +identify canonical topic pages (not the regulator's home page). This +discovery pass is separate from the monitoring sweep and must be small. + +**Present them and STOP.** Show the shortlist to the user and wait +before doing anything else: + +> "Before I start monitoring, here are the 5 authoritative sources I'd +> watch for **Pillar II**: +> 1. OECD — Pillar Two: +> 2. European Commission — Pillar Two implementation: +> 3. HMRC — Multinational Top-up Tax: +> 4. Tax Foundation — Global minimum tax tracker: +> 5. KPMG — BEPS 2.0 tracker: +> +> Want me to swap any out? And do you have any of your own sources +> (regulator pages, internal trackers, subscription-free trade alerts, +> etc.) you want me to add on top of these?" + +**Wait for the user's reply.** The user may: + +- Approve as-is → proceed. +- Ask to swap or remove one of the 5 → re-run discovery for that slot + with the constraint they gave. +- Add their own seed URLs → append them to `seed_sources_by_topic` + under the appropriate topic key. Seed sources are **not** counted + against the "top 5" — a topic can end up with 5 auto-discovered plus + N user seeds. A seed that spans multiple topics is added under each + relevant topic key. +- Ask you to lower the target from 5 → honor it. + +**Do not skip this confirmation, even on a re-setup.** If the user +later adds a topic, repeat this checkpoint for the new topic before +touching the sweep. + +### Step 2 — Capture the WorkIQ org profile (setup) + +Derive a light org profile from WorkIQ and show it for confirmation: + +- `workiq_get_my_profile` → job title, department, office +- `workiq_get_my_manager` → manager and their department (context only) +- `workiq_get_relevant_people` (limit 10) → likely function-area + collaborators + +Propose a `function_area_keywords` list (5–15 words: department name and +variants, the user's job function, key collaborator team names, obvious +topic proxies). The user edits and confirms. + +If WorkIQ is unavailable on the current platform, ask the user for +`function_area_keywords` manually. The rest of the skill works unchanged. + +### Step 3 — Save the profile + +Write `config.json`: + +```json +{ + "profile_name": "pillar-ii", + "watch_topics": [ + { "key": "pillar-two", "name": "OECD Pillar II / GloBE", + "keywords": ["Pillar Two", "GloBE", "global minimum tax", + "IIR", "UTPR", "QDMTT", "DMTT", "top-up tax"] } + ], + "jurisdictions": ["global"], + "sources_by_topic": { + "pillar-two": [ + { "name": "OECD — Pillar Two", + "url": "https://www.oecd.org/tax/beps/pillar-two-model-rules-in-a-nutshell.pdf", + "kind": "regulator" } + ] + }, + "seed_sources_by_topic": {}, + "cadence": "weekly", + "window_days": 7, + "runtime_budget": { + "max_items": 40, + "max_fallback_searches": 2, + "max_fetches_per_source": 2 + }, + "delivery": { "type": "email", "to": ["me@example.com"] }, + "workiq_context": { + "captured_at": "2026-07-23T11:00:00Z", + "department": "Global Tax Policy", + "job_title": "Director, International Tax", + "function_area_keywords": ["Pillar Two", "GloBE", "international tax", + "transfer pricing", "top-up tax"], + "collaborator_teams": ["Transfer Pricing", "Tax Controversy"] + }, + "last_run_at": null +} +``` + +### Step 4 — Load config and scope the sweep (every run) + +- Read `config.json` for the profile. +- Resolve the window: from `last_run_at` (if set) to now, else the past + `window_days`. +- Restate the scope back to the user in one line so they can interrupt + if it looks wrong (interactive runs only). + +### Step 5 — Sweep the locked source list (bounded) + +Sweep proceeds in this order and stops when the budget is met: + +1. **Every source in `sources_by_topic[topic]` and + `seed_sources_by_topic[topic]` for each topic.** `web_fetch` each URL. + Extract items dated within the window. + - Cap `max_fetches_per_source` (default 2). If a source's index page + links to individual items, follow at most that many per source. +2. **Bounded fallback search** only for topics where every locked source + (auto-discovered + user seeds) returned zero items in the window. At + most one `web_search` per topic, at most `max_fallback_searches` + total across the run (default 2). Filter results by the reputable- + domain allowlist. Discard non-matching results. +3. **Stop when `max_items` is reached** (default 40). Prefer regulator + sources > tracker sources > firm alerts when trimming. + +Runtime budget defaults, all configurable in `config.runtime_budget`: + +- `max_items`: 40 — total items recorded per run. +- `max_fallback_searches`: 2 — `web_search` calls per run. +- `max_fetches_per_source`: 2 — items followed from one source's index. + +Rules that always apply: + +- Validate every date against the window; drop out-of-window items. +- Never bypass a paywall; skip subscriber-only content. +- Deduplicate items with the same title + jurisdiction, keeping the + more authoritative source (regulator > tracker > firm alert). + +### Step 6 — Classify each item + +Record, for every item: + +- **topic** — one of the profile's watch-topic keys. +- **jurisdiction** — from the profile's list, or `global` / + `local: `. +- **stage** — `proposed` / `in-consultation` / `passed` / + `regulatory-guidance` / `in-force` / `litigation` / `withdrawn`. +- **date** — ISO date the source is dated or the action took place. +- **title** — the source's short title, verbatim. +- **summary** — 1–2 sentences in the skill's own words. +- **source_name** — publisher's short name. +- **source_url** — canonical public URL actually retrieved + (http/https/mailto only; other schemes are dropped at render time). + +Stage inference guidance is in `references/sources-and-taxonomy.md`. + +### Step 7 — Flag team relevance (WorkIQ-derived) + +For each item, set `relevant_to_your_team` to `true` if any +`workiq_context.function_area_keywords` phrase appears (case-insensitive) +in the item's title, summary, or matched topic keywords. Otherwise +`false`. + +This is a soft highlight, not an impact rubric. The dashboard uses it +to sort and badge; the skill never says "this affects your business" — +that is a human judgment. + +### Step 8 — Build the dashboard + +1. Write items to `working/regulation-items.json` as a JSON object with the + shape `{"items": [ ... ]}` (not a bare array). Each entry follows the + Step 6 schema. +2. Run the bundled generator (single-line command): + ``` + python scripts/build_dashboard.py --config config.json --items working/regulation-items.json --output output/regulation-dashboard.html + ``` +3. Verify the file was written before telling the user it is ready. + +The dashboard is a single self-contained HTML file — KPI tiles (total +items, count per topic, team-relevant count), a table color-coded by +stage with **client-side sortable columns** (click any column header, or +focus and press Enter/Space, to sort), a team-relevant badge on flagged +rows, and every row linking to its primary source. + +### Step 9 — Deliver and update last-run + +- **Inline**: a short summary — window covered, item counts by topic, + the top team-relevant items (title, jurisdiction, stage, date). If a + topic produced no items, say **"No significant developments this + period"** for that topic. Do not pad, do not speculate. +- **Scheduled runs**: send per the profile's delivery block. If `type` + is `email`, send the HTML dashboard as an attachment to the addresses + in `to`. The pre-authorized recipient is the user themselves. Any + additional recipient requires explicit user confirmation on an + interactive run and is never added on an unattended run. +- **Update the config** with `last_run_at = `. + +## Guardrails + +- **Never fabricate** a bill number, date, quote, or enactment status. + If a fact cannot be confirmed from a retrieved public source, mark it + `[unverified]` in the summary. Report gaps honestly. +- **No speculation, rumors, or unofficial sources.** Do not include + items sourced from anonymous leaks, social-media speculation, + unattributed drafts, or "reportedly" / "expected to" claims without a + named official source. If an item cannot be tied to a specific + document or announcement from a source on the profile's locked list + (or the reputable-domain allowlist for fallback search), drop it. + Better silence than noise. +- **Report empty categories explicitly.** If a topic produced no items + in the window, say **"No significant developments this period"** for + that topic in both the inline summary and the dashboard. Do not pad + with low-signal filler. +- **Public sources only.** Never bypass a paywall or reproduce + paywalled or copyrighted text. Summarize in the skill's own words and + link the source. +- **Locked source list.** The sweep only visits URLs in the profile's + `sources_by_topic` and `seed_sources_by_topic`. That list is set at + interactive setup with explicit user confirmation — the skill cannot + start the monitoring sweep until the user has approved the sources. + Fallback search is bounded by `runtime_budget.max_fallback_searches` + and filtered by the reputable-domain allowlist. Do not add new + sources on an unattended run — that requires interactive re-setup. +- **Monitoring, not advice.** The skill never states a filing position, + a legal conclusion, or a business impact. `relevant_to_your_team` is + a soft keyword-match highlight. +- **Confirm before external sends.** Emailing anyone other than the + user requires explicit confirmation on an interactive run; unattended + runs never add recipients. +- **Confidentiality and sensitivity.** If a retrieved source carries a + confidentiality label or sensitivity marking (for example + "Confidential", "Internal Only", "Restricted", or an enterprise + information-protection label), or contains unreleased figures, + customer or partner identifiers, or names that aren't public yet, + drop it — this skill uses public sources only. Never add PII, + customer identifiers, or non-public attribution to the dashboard + beyond the function-area keywords the user confirmed at setup. +- **Cover exactly the requested topics** — no more, no fewer. +- **Do not reproduce third-party copyrighted text** verbatim; paraphrase + in the skill's own words. +- **Verify delivery.** Confirm the dashboard file exists before + reporting success. If the delivery block failed, report the failure. +- **Cite by exact source name** and validate every date against the + requested window. + +## References + +- `references/sources-and-taxonomy.md` — reputable-domain allowlist, + item classification taxonomy, stage-inference rules, and per-topic + search-query templates. +- `scripts/build_dashboard.py` — self-contained dashboard generator + (Python standard library only; embeds a small vanilla-JS sorter for + the items table). diff --git a/submissions/regulation-monitor/metadata.json b/submissions/regulation-monitor/metadata.json new file mode 100644 index 00000000..3724c15c --- /dev/null +++ b/submissions/regulation-monitor/metadata.json @@ -0,0 +1,11 @@ +{ + "name": "Regulation Monitor", + "description": "Configure once, then on a schedule sweeps a locked list of authoritative sources (auto-discovered at setup, confirmed by the user) plus any user-supplied seeds. Classifies each item, flags items relevant to the user's team using a light WorkIQ-derived profile, and renders a self-contained HTML dashboard. A tightly-bounded fallback web search is used only when a locked source is silent in the window. Domain-agnostic (tax, privacy, AI/ML, healthcare, finance, ESG, labor, etc.).", + "platforms": ["Cowork", "Scout"], + "tags": ["regulation", "monitoring", "compliance", "dashboard", "research"], + "author": "Jagmeet Chabra", + "authorUrl": "https://github.com/jchha001", + "version": "1.0.0", + "createdAt": "2026-07-23", + "updatedAt": "2026-07-23" +} diff --git a/submissions/regulation-monitor/references/sources-and-taxonomy.md b/submissions/regulation-monitor/references/sources-and-taxonomy.md new file mode 100644 index 00000000..f9af0936 --- /dev/null +++ b/submissions/regulation-monitor/references/sources-and-taxonomy.md @@ -0,0 +1,114 @@ +# Sources, taxonomy, and search-query templates + +This file is loaded by the skill during setup and every sweep. It defines the +default reputable-domain allowlist, the item classification taxonomy, and how +per-topic search queries are built. + +## Default reputable-domain allowlist + +The sweep only accepts `web_search` results whose domain matches one of these +patterns (or an extension the user added at setup). Seed sources the user +provided are always honored regardless of domain. + +### Government and inter-governmental (broad) + +- `*.gov` — U.S. federal, state, local +- `*.gov.uk` — United Kingdom +- `*.gc.ca` — Canada federal +- `*.gov.au` — Australia +- `*.govt.nz` — New Zealand +- `*.europa.eu` — European Union institutions +- `*.oecd.org` — OECD +- `*.un.org` — United Nations +- `*.who.int` — WHO +- `*.bis.org` — Bank for International Settlements +- `*.imf.org` — IMF +- `*.worldbank.org` — World Bank + +### Sector-specific regulators (opt-in but pre-approved) + +Financial: `sec.gov`, `cftc.gov`, `federalreserve.gov`, `fdic.gov`, `occ.gov`, +`ecb.europa.eu`, `eba.europa.eu`, `esma.europa.eu`, `fca.org.uk`, `bankofengland.co.uk`. + +Privacy and data: `ftc.gov`, `edpb.europa.eu`, `ico.org.uk`, `cnil.fr`, +`bfdi.bund.de`, `oaic.gov.au`, `priv.gc.ca`. + +Competition: `justice.gov`, `ftc.gov`, `ec.europa.eu`, `cma.gov.uk`, +`bundeskartellamt.de`, `competitionbureau.gc.ca`. + +Health: `hhs.gov`, `fda.gov`, `cms.gov`, `ema.europa.eu`, `mhra.gov.uk`. + +Environment / ESG: `epa.gov`, `energy.gov`, `iso.org`, `issb.ifrs.org`, +`sec.gov` (climate disclosure), `efrag.org`. + +Labor and workforce: `dol.gov`, `eeoc.gov`, `nlrb.gov`. + +Tax: `irs.gov`, `treasury.gov`, `hmrc.gov.uk` (state DOR sites are already covered by `.gov`). + +Standards and technical: `nist.gov`, `iso.org`, `ieee.org`, `w3.org`, +`iana.org`. + +### Reputable trackers and think tanks (opt-in) + +- `taxfoundation.org` — Tax Foundation +- `taxpolicycenter.org` — Tax Policy Center +- `mtc.gov` — Multistate Tax Commission +- `ncsl.org` — National Conference of State Legislatures +- `cost.org` — Council on State Taxation (public materials only) +- `iapp.org` — International Association of Privacy Professionals +- `future-of-privacy-forum.org` +- `brookings.edu`, `hoover.stanford.edu`, `aei.org`, `epic.org` + +The user can extend this list via `domain_allowlist_extensions` in the config. +Add sparingly — the whole point of the allowlist is signal, not volume. + +## Item taxonomy + +Each item recorded by the sweep has: + +| Field | Values | Notes | +|-------|--------|-------| +| `topic` | one of the profile's `watch_topics[].key` | required | +| `jurisdiction` | from profile's `jurisdictions`, or `global` / `local: ` | required | +| `stage` | `proposed` / `in-consultation` / `passed` / `regulatory-guidance` / `in-force` / `litigation` / `withdrawn` | required | +| `date` | ISO date the source is dated or the action took place | required | +| `title` | source's short title, verbatim | required | +| `summary` | 1-2 sentences in the model's own words | required | +| `source_name` | publisher's short name | required | +| `source_url` | canonical public URL that was actually retrieved | required | +| `relevant_to_your_team` | `true` if any function-area keyword matches | derived at Step 7 | + +## Search-query templates + +The sweep builds one query per watch topic. Use this shape: + +``` +("" OR "" OR "") AND (regulation OR guidance OR rule OR bill OR "final rule" OR consultation) AND ( OR ) after: +``` + +Guidance: + +- Prefer exact-quoted phrases for multi-word keywords ("EU AI Act", not + EU AI Act) to reduce false positives. +- Add jurisdiction terms only if the profile's list is short and specific. + If the profile lists 6+ jurisdictions, drop the jurisdiction clause and + filter by domain after. +- Use `after:YYYY-MM-DD` (or the equivalent recency filter available in the + web_search tool) to enforce the window at query time. +- Fan topics out in parallel, one query per topic. Do not run multiple + redundant variants of one topic. + +## Stage inference + +Pick the stage from the source's own language: + +- "introduced", "filed", "sponsors" → `proposed` +- "consultation", "call for evidence", "request for comment", "notice of proposed rulemaking" → `in-consultation` +- "passed the Senate", "royal assent", "adopted by Council" → `passed` +- "guidance", "circular", "policy statement", "notice", "FAQ" → `regulatory-guidance` +- "effective", "in force", "applies from" (past date) → `in-force` +- "court", "ruling", "opinion", "settlement", "consent decree" → `litigation` +- "withdrawn", "vacated", "rescinded" → `withdrawn` + +If the source is genuinely ambiguous, pick the earliest applicable stage and +say so in the summary. diff --git a/submissions/regulation-monitor/scripts/build_dashboard.py b/submissions/regulation-monitor/scripts/build_dashboard.py new file mode 100644 index 00000000..dd911ba0 --- /dev/null +++ b/submissions/regulation-monitor/scripts/build_dashboard.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +"""Build a self-contained HTML dashboard from a regulation-monitor run. + +Consumes: + --config path to profile config.json (for profile name, topics, cadence) + --items path to a JSON file with shape {"items": [ ... ]} + --output path to write the HTML dashboard + +Item schema (each entry in items[]): + topic str - one of the profile's watch_topics[].key + jurisdiction str - e.g. "EU", "US-federal", "UK", "global" + stage str - proposed | in-consultation | passed | + regulatory-guidance | in-force | litigation | + withdrawn + date str - ISO date + title str - source's short title, verbatim + summary str - 1-2 sentence summary + source_name str - publisher's short name + source_url str - canonical public URL (http/https/mailto only; + other schemes are dropped at render time) + relevant_to_your_team bool - team-relevance flag from Step 7 + +Uses only the Python standard library so it runs in restricted sandboxes. +The generated HTML embeds a small vanilla-JS sorter that sorts by any +column when its header is clicked (no network requests, no external assets). +""" + +from __future__ import annotations + +import argparse +import html +import json +import sys +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +STAGE_ORDER = [ + "proposed", + "in-consultation", + "passed", + "regulatory-guidance", + "in-force", + "litigation", + "withdrawn", +] + +STAGE_COLORS = { + "proposed": "#6b7280", + "in-consultation": "#0ea5e9", + "passed": "#2563eb", + "regulatory-guidance": "#7c3aed", + "in-force": "#059669", + "litigation": "#dc2626", + "withdrawn": "#9ca3af", +} + + +def load_json(path: Path) -> dict[str, Any]: + """Read and parse a JSON file, raising SystemExit on IO or parse errors. + + Callers get a clean 'exit 2 with a helpful message' instead of a raw + traceback when the input is missing, unreadable, or malformed. + """ + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + print(f"could not read {path}: {exc}", file=sys.stderr) + raise SystemExit(2) + try: + return json.loads(text) + except json.JSONDecodeError as exc: + print(f"invalid JSON in {path}: {exc}", file=sys.stderr) + raise SystemExit(2) + + +def safe_url(value: Any) -> str: + """Return the URL only if it uses a safe scheme (http/https/mailto). + + Prevents javascript:/data:/vbscript: injection when items are sourced + from external content the skill did not author. + """ + if not value: + return "" + text = str(value).strip() + lowered = text.lower() + if lowered.startswith(("http://", "https://", "mailto:")): + return text + return "" + + +def esc(value: Any) -> str: + return html.escape("" if value is None else str(value)) + + +def stage_pill(stage: str) -> str: + color = STAGE_COLORS.get(stage, "#374151") + return ( + f'{esc(stage)}' + ) + + +def relevant_badge(is_relevant: bool) -> str: + if not is_relevant: + return "" + return ( + '' + "team-relevant" + ) + + +def sorted_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Initial server-side sort: team-relevant first, then stage order, + then date descending within each group. Users can override any + dimension via the client-side column sorter. + + Implemented as two stable passes because Python's sort doesn't + support a per-key `reverse` for individual tuple elements: sort by + date descending first, then stable-sort by (relevant, stage) so the + date-desc order is preserved inside each stable group. + """ + by_date_desc = sorted( + items, + key=lambda item: str(item.get("date") or ""), + reverse=True, + ) + def group_key(item: dict[str, Any]) -> tuple[int, int]: + relevant_first = 0 if item.get("relevant_to_your_team") is True else 1 + stage = str(item.get("stage") or "") + stage_idx = ( + STAGE_ORDER.index(stage) if stage in STAGE_ORDER else len(STAGE_ORDER) + ) + return (relevant_first, stage_idx) + return sorted(by_date_desc, key=group_key) + + +def build_html(config: dict[str, Any], items: list[dict[str, Any]]) -> str: + profile_name = esc(config.get("profile_name", "regulation-monitor")) + cadence = esc(config.get("cadence", "on demand")) + window_raw = config.get("window_days") + if window_raw is None: + window_days = 7 + else: + try: + window_days = int(window_raw) + except (TypeError, ValueError): + window_days = 7 + generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + topic_name_by_key: dict[str, str] = {} + for topic in config.get("watch_topics", []): + key = str(topic.get("key", "")).strip() + if not key: + continue # skip topics without a key rather than colliding on "" + topic_name_by_key[key] = str(topic.get("name", key)) + + total = len(items) + per_topic = Counter(str(item.get("topic") or "") for item in items) + team_relevant = sum(1 for item in items if item.get("relevant_to_your_team") is True) + + ordered = sorted_items(items) + + # KPI tiles + tiles_html: list[str] = [ + _tile("Total items", str(total)), + _tile("Team-relevant", str(team_relevant)), + ] + for topic in config.get("watch_topics", []): + key = str(topic.get("key", "")).strip() + if not key: + continue # matches topic_name_by_key skip above + tiles_html.append(_tile(str(topic.get("name", key)), str(per_topic.get(key, 0)))) + + # Rows. Each cell gets a data-sort attribute the JS sorter reads: + # numeric where useful (stage order), ISO string where lexicographic + # equals chronological (date), plain lowercased text elsewhere. + rows_html: list[str] = [] + for item in ordered: + topic_key = str(item.get("topic") or "") # None -> "" not "None" + topic_display = topic_name_by_key.get(topic_key, topic_key or "-") + stage = str(item.get("stage") or "") + stage_sort = ( + str(STAGE_ORDER.index(stage)) + if stage in STAGE_ORDER + else str(len(STAGE_ORDER)) + ) + date_str = str(item.get("date") or "") + jurisdiction = str(item.get("jurisdiction") or "-") + title = str(item.get("title") or "(untitled)") + summary = str(item.get("summary") or "") + source_name = str(item.get("source_name") or "") + source_url = safe_url(item.get("source_url")) + source_link = ( + f'{esc(source_name)}' + if source_url + else esc(source_name) + ) + + rows_html.append( + "" + f'{esc(date_str or "-")}' + f'{esc(topic_display)} {relevant_badge(item.get("relevant_to_your_team") is True)}' + f'{esc(jurisdiction)}' + f'{stage_pill(stage)}' + f'' + f'
{esc(title)}
' + f'
{esc(summary)}
' + f'{source_link}' + "" + ) + + if not rows_html: + rows_html.append( + 'No significant developments this period.' + ) + + # Empty-topic callout: list any watch topic that produced zero items. + topics_with_items = {str(item.get("topic") or "") for item in items} + empty_topics: list[str] = [] + for topic in config.get("watch_topics", []): + key = str(topic.get("key", "")).strip() + if not key: + continue # skip topics without a real key (see tiles loop above) + if key not in topics_with_items: + empty_topics.append(str(topic.get("name", key))) + + empty_html = "" + if empty_topics: + rows = "".join( + f"
  • {esc(name)} — No significant developments this period.
  • " + for name in empty_topics + ) + empty_html = ( + '
    ' + "

    Quiet this period

    " + f"
      {rows}
    " + "
    " + ) + + style = _stylesheet() + script = _sorter_script() + return f""" + + + + +Regulation Monitor — {profile_name} + + + +
    +
    +
    +
    +
    Regulation Monitor
    +

    {profile_name}

    +
    +
    +
    +
    Cadence {cadence}
    +
    Window last {esc(window_days)} days
    +
    Generated {esc(generated_at)}
    +
    +
    + +
    + {''.join(tiles_html)} +
    + +
    +

    Items — click any column header to sort

    + + + + + + + + + + + + + {''.join(rows_html)} + +
    DateTopicJurisdictionStageTitle & summarySource
    +
    + +{empty_html} + +
    +
    Regulation Monitor is a monitoring tool. Nothing on this dashboard is legal, tax, or compliance advice. Team-relevance is a keyword match, not an impact assessment.
    +
    + + + +""" + + +def _tile(label: str, value: str) -> str: + return ( + '
    ' + f'
    {esc(value)}
    ' + f'
    {esc(label)}
    ' + "
    " + ) + + +def _stylesheet() -> str: + # Kept simple and self-contained. No external assets. + return """ +:root { + --bg:#f8fafc; --card:#ffffff; --ink:#0f172a; --muted:#64748b; + --line:#e2e8f0; --accent:#4f46e5; --accent-2:#0ea5e9; + --shadow:0 1px 2px rgba(15,23,42,.04), 0 1px 3px rgba(15,23,42,.06); +} +*{box-sizing:border-box} +html,body{margin:0;padding:0;background:var(--bg);color:var(--ink); + font:14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;} +header{padding:24px 32px;background:linear-gradient(180deg,#ffffff, #f1f5f9); + border-bottom:1px solid var(--line);display:flex;justify-content:space-between; + align-items:flex-end;gap:24px;flex-wrap:wrap} +.brand{display:flex;gap:14px;align-items:center} +.brand .dot{width:12px;height:12px;border-radius:50%; + background:linear-gradient(135deg,var(--accent),var(--accent-2));box-shadow:var(--shadow)} +.eyebrow{color:var(--muted);text-transform:uppercase;letter-spacing:.08em; + font-size:11px;font-weight:600} +h1{margin:2px 0 0;font-size:22px;font-weight:600} +.meta{display:flex;gap:20px;color:var(--muted);font-size:12px;flex-wrap:wrap} +.meta strong{color:var(--ink);font-weight:600} + +section{padding:24px 32px} +section h2{margin:0 0 12px;font-size:14px;text-transform:uppercase; + letter-spacing:.06em;color:var(--muted);font-weight:600} +section h2 .hint{color:var(--muted);text-transform:none;letter-spacing:0; + font-size:12px;font-weight:400;margin-left:6px} + +.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); + gap:12px;padding:16px 32px 0} +.tile{background:var(--card);border:1px solid var(--line);border-radius:10px; + padding:14px 16px;box-shadow:var(--shadow)} +.tile-value{font-size:22px;font-weight:600} +.tile-label{color:var(--muted);font-size:12px;margin-top:2px} + +table{width:100%;border-collapse:collapse;background:var(--card); + border:1px solid var(--line);border-radius:10px;overflow:hidden;box-shadow:var(--shadow)} +thead th{background:#f1f5f9;color:var(--muted);text-align:left;font-weight:600; + padding:10px 12px;font-size:11px;text-transform:uppercase;letter-spacing:.06em; + border-bottom:1px solid var(--line);cursor:pointer;user-select:none; + position:relative;outline:none} +thead th:hover{color:var(--ink)} +thead th:focus-visible{color:var(--ink);box-shadow:inset 0 -2px 0 var(--accent)} +thead th::after{content:"";display:inline-block;margin-left:6px;opacity:.35; + font-size:10px;transform:translateY(-1px)} +thead th[aria-sort="ascending"]::after{content:"\\25B2";opacity:1;color:var(--accent)} +thead th[aria-sort="descending"]::after{content:"\\25BC";opacity:1;color:var(--accent)} +tbody td{padding:12px;vertical-align:top;border-bottom:1px solid var(--line); + font-size:13px} +tbody tr:last-child td{border-bottom:none} +.title{font-weight:600;margin-bottom:2px} +.summary{color:var(--muted)} +.empty{color:var(--muted);text-align:center;padding:24px} +.pill{color:#fff;padding:3px 8px;border-radius:999px;font-size:11px; + font-weight:600;text-transform:lowercase} +.badge{display:inline-block;margin-left:8px;color:#3730a3;background:#eef2ff; + padding:2px 8px;border-radius:999px;font-size:10px;font-weight:600; + text-transform:lowercase;letter-spacing:.02em} +a{color:var(--accent);text-decoration:none} +a:hover{text-decoration:underline} + +footer{padding:16px 32px 32px;color:var(--muted);font-size:11px} +.empty-topics ul{list-style:none;margin:0;padding:0;background:var(--card); + border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow); + overflow:hidden} +.empty-topics li{padding:10px 14px;border-bottom:1px solid var(--line); + color:var(--muted);font-size:13px} +.empty-topics li:last-child{border-bottom:none} +""" + + +def _sorter_script() -> str: + """Vanilla-JS click-to-sort. Reads td[data-sort] for the sort key, + respects data-type on th ('number' | 'date' | 'text'), toggles + ascending / descending, and updates aria-sort for accessibility. + """ + return """ +(function(){ + var table = document.getElementById('items-table'); + if (!table) return; + var thead = table.tHead; + var tbody = table.tBodies[0]; + if (!thead || !tbody) return; + var ths = thead.rows[0].cells; + function doSort(th, colIndex) { + var type = th.getAttribute('data-type') || 'text'; + var current = th.getAttribute('aria-sort'); + var asc = current !== 'ascending'; + for (var j = 0; j < ths.length; j++) ths[j].removeAttribute('aria-sort'); + th.setAttribute('aria-sort', asc ? 'ascending' : 'descending'); + var rows = Array.prototype.slice.call(tbody.rows).filter(function(r){ + return !(r.cells.length === 1 && r.cells[0].getAttribute('colspan')); + }); + rows.sort(function(a, b){ + var av = (a.cells[colIndex] && a.cells[colIndex].getAttribute('data-sort')) || ''; + var bv = (b.cells[colIndex] && b.cells[colIndex].getAttribute('data-sort')) || ''; + var cmp; + if (type === 'number') { + cmp = (parseFloat(av) || 0) - (parseFloat(bv) || 0); + } else if (type === 'date') { + // ISO 8601 date strings sort correctly as byte-string comparison — + // avoid Date parsing here so malformed inputs stay deterministic. + cmp = (av < bv) ? -1 : (av > bv ? 1 : 0); + } else { + cmp = av.localeCompare(bv); + } + return asc ? cmp : -cmp; + }); + var frag = document.createDocumentFragment(); + rows.forEach(function(r){ frag.appendChild(r); }); + tbody.appendChild(frag); + } + for (var i = 0; i < ths.length; i++) { + (function(colIndex){ + var th = ths[colIndex]; + th.addEventListener('click', function(){ doSort(th, colIndex); }); + // Keyboard accessibility: Enter or Space triggers the same sort. + th.addEventListener('keydown', function(e){ + if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { + e.preventDefault(); + doSort(th, colIndex); + } + }); + })(i); + } +})(); +""" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--items", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args(argv) + + if not args.config.exists(): + print(f"config not found: {args.config}", file=sys.stderr) + return 2 + if not args.items.exists(): + print(f"items not found: {args.items}", file=sys.stderr) + return 2 + + config = load_json(args.config) + if not isinstance(config, dict): + print( + f"config file must contain a JSON object; got {type(config).__name__}", + file=sys.stderr, + ) + return 2 + items_doc = load_json(args.items) + raw_items = items_doc.get("items", []) if isinstance(items_doc, dict) else [] + if not isinstance(raw_items, list): + print( + f"items file must contain an 'items' list; got {type(raw_items).__name__}", + file=sys.stderr, + ) + return 2 + items = [item for item in raw_items if isinstance(item, dict)] + dropped = len(raw_items) - len(items) + if dropped: + print( + f"warning: dropped {dropped} non-dict entries from items[]", + file=sys.stderr, + ) + + html_out = build_html(config, items) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(html_out, encoding="utf-8") + print(str(args.output)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())