From 645e7ff6732bb013e7fa15ce1c66c1ff518e59b2 Mon Sep 17 00:00:00 2001 From: fedorovvvv Date: Wed, 6 May 2026 21:00:31 +0400 Subject: [PATCH 1/6] feat(ui): shared UI primitives + npm update notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Button, Code-with-copy, Dialog under template/src/shared/ui/ and a modalManager service under template/src/shared/services/modal/ so widgets can open dialogs without per-call mounting. ModalRoot is mounted once in +layout.svelte. Add /api/update-check endpoint that probes registry.npmjs.org for the latest @forgeplan/web (5-min server-process cache, 5-second timeout, GET only, never throws). VersionFooter polls it once at mount and every 30 minutes; when hasUpdate, an UpdateButton appears above the footer and opens UpdateDialog (current → latest + copyable manual command). Auto-update is intentionally out of scope: running `npx @forgeplan/web update` from the running server would rmSync the very files serving the request. Dialog explains this and offers the manual path only. Rule 22 amended to allow exactly one non-forgeplan endpoint hitting the literal URL https://registry.npmjs.org/@forgeplan/web/latest. Refs: PRD-013, RFC-012, EVID-017 Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/rules/22-readonly-proxy.md | 28 ++ ...oint-probe-for-shared-ui-update-checker.md | 118 +++++++ ...d-ui-primitives-npm-update-notification.md | 290 +++++++++++++++++ ...-primitives-modalmanager-update-checker.md | 308 ++++++++++++++++++ template/src/routes/+layout.svelte | 2 + .../src/routes/api/update-check/+server.ts | 79 +++++ template/src/shared/server/index.ts | 1 + template/src/shared/server/semver.ts | 62 ++++ template/src/shared/services/index.ts | 1 + template/src/shared/services/modal/index.ts | 1 + .../services/modal/modal-manager.svelte.ts | 69 ++++ template/src/shared/ui/README.md | 117 +++++++ template/src/shared/ui/button/Button.svelte | 104 ++++++ template/src/shared/ui/button/index.ts | 1 + template/src/shared/ui/code/Code.svelte | 165 ++++++++++ template/src/shared/ui/code/index.ts | 1 + template/src/shared/ui/dialog/Dialog.svelte | 181 ++++++++++ template/src/shared/ui/dialog/index.ts | 1 + template/src/shared/ui/index.ts | 4 + template/src/shared/ui/modal/ModalRoot.svelte | 8 + template/src/shared/ui/modal/index.ts | 1 + .../version-footer/api/update-check.svelte.ts | 15 + .../version-footer/ui/UpdateButton.svelte | 88 +++++ .../version-footer/ui/UpdateDialog.svelte | 131 ++++++++ .../version-footer/ui/VersionFooter.svelte | 25 +- 25 files changed, 1800 insertions(+), 1 deletion(-) create mode 100644 .forgeplan/evidence/EVID-017-smoke-svelte-check-live-endpoint-probe-for-shared-ui-update-checker.md create mode 100644 .forgeplan/prds/PRD-013-shared-ui-primitives-npm-update-notification.md create mode 100644 .forgeplan/rfcs/RFC-012-shared-ui-primitives-modalmanager-update-checker.md create mode 100644 template/src/routes/api/update-check/+server.ts create mode 100644 template/src/shared/server/semver.ts create mode 100644 template/src/shared/services/index.ts create mode 100644 template/src/shared/services/modal/index.ts create mode 100644 template/src/shared/services/modal/modal-manager.svelte.ts create mode 100644 template/src/shared/ui/README.md create mode 100644 template/src/shared/ui/button/Button.svelte create mode 100644 template/src/shared/ui/button/index.ts create mode 100644 template/src/shared/ui/code/Code.svelte create mode 100644 template/src/shared/ui/code/index.ts create mode 100644 template/src/shared/ui/dialog/Dialog.svelte create mode 100644 template/src/shared/ui/dialog/index.ts create mode 100644 template/src/shared/ui/index.ts create mode 100644 template/src/shared/ui/modal/ModalRoot.svelte create mode 100644 template/src/shared/ui/modal/index.ts create mode 100644 template/src/widgets/version-footer/api/update-check.svelte.ts create mode 100644 template/src/widgets/version-footer/ui/UpdateButton.svelte create mode 100644 template/src/widgets/version-footer/ui/UpdateDialog.svelte diff --git a/.claude/rules/22-readonly-proxy.md b/.claude/rules/22-readonly-proxy.md index 2782cf6..4c108dd 100644 --- a/.claude/rules/22-readonly-proxy.md +++ b/.claude/rules/22-readonly-proxy.md @@ -27,6 +27,31 @@ This is the only flag-only invocation permitted from `/api/*`. Any new flag-only or subcommand entry requires an updating Forgeplan artifact and a revision of this rule. See PRD-012 / RFC-011. +## Allow-list extension: `/api/update-check` (non-forgeplan) + +`/api/update-check` is the **single** non-forgeplan endpoint permitted from +`/api/*`. It probes the npm registry for the latest published version of +`@forgeplan/web` so the UI can surface an "Update available" affordance. + +Constraints (every one of these is enforceable from the diff): + +- Method: `GET` only. +- URL: the **string literal** `https://registry.npmjs.org/@forgeplan/web/latest`. + No interpolation, no query params, no user input on the URL path. +- No spawn, no host filesystem write, no Forgeplan invocation. The only + side-effect is a process-local in-memory cache (5 min TTL, single + inflight promise). +- Headers: `accept: application/json` and a static `user-agent`. No cookies, + no credentials. +- Response shape mirrors the standard envelope: `{ ok, data: { current, + latest, hasUpdate }, cmd, error? }` with `current = __FORGEPLAN_WEB_VERSION__`. +- Network failures (timeout, non-2xx, JSON parse error) MUST fall back to + `{ ok: false, error, data: { ..., hasUpdate: false } }` — never throw. + +Any additional non-forgeplan endpoint (whether it hits npm, GitHub, +crates.io, or anything else) requires a new Forgeplan artifact and a fresh +amendment to this rule. See PRD-013 / RFC-012. + ## Forbidden `forgeplan` subcommands from any `/api/*` endpoint Any subcommand that mutates the workspace: @@ -69,3 +94,6 @@ browser invalidates that. `args[0] ∈ READ_ONLY_SUBCOMMANDS` before spawning, and the constant MUST match this allow-list (see rule above). The check is the runtime backstop for review-time enforcement. +- `grep -RIn "fetch(" template/src/routes/api/` must show external URLs + only inside `update-check/+server.ts`, and the URL must appear as a + string literal (`https://registry.npmjs.org/@forgeplan/web/latest`). diff --git a/.forgeplan/evidence/EVID-017-smoke-svelte-check-live-endpoint-probe-for-shared-ui-update-checker.md b/.forgeplan/evidence/EVID-017-smoke-svelte-check-live-endpoint-probe-for-shared-ui-update-checker.md new file mode 100644 index 0000000..78a695e --- /dev/null +++ b/.forgeplan/evidence/EVID-017-smoke-svelte-check-live-endpoint-probe-for-shared-ui-update-checker.md @@ -0,0 +1,118 @@ +--- +depth: standard +id: EVID-017 +kind: evidence +last_modified_at: 2026-05-06T16:58:57.023400+00:00 +last_modified_by: claude-code/2.1.131 +links: +- target: PRD-013 + relation: informs +- target: RFC-012 + relation: informs +status: draft +title: smoke + svelte-check + live endpoint probe for shared UI + update checker +--- + +# EVID-017: smoke + svelte-check + live endpoint probe for shared UI + update checker + +| Field | Value | +| ----------- | ---------------------- | +| Status | Draft | +| Created | 2026-05-06 | +| Valid Until | 2026-08-06 | +| Target | PRD-013 / RFC-012 | + +## Structured Fields + +evidence_type: test +verdict: supports +congruence_level: 3 + +## Measurement + +Three direct probes against the surface introduced by PRD-013 / RFC-012: + +1. `cd template && npm run check` — `svelte-kit sync` followed by + `svelte-check --tsconfig ./tsconfig.json`. Exercises every `.svelte`, + `.svelte.ts`, and `.ts` file under `template/src/` (including the new + `shared/ui/`, `shared/services/modal/`, and version-footer additions) + for type errors and a11y warnings. +2. `npm run smoke` at the repo root — rebuilds `dist/` from scratch + (`scripts/build.mjs --clean`), `init -y` against a scratch dir, then + `init -y --force`, then boots `node dist/index.js`, and probes + `/api/health`, `/api/list`, `/`. This is the same smoke harness + already used to gate releases. +3. Live spawn of the freshly built `dist/index.js` on PORT=15999 with + FORGEPLAN_CWD=/tmp + curl against the new endpoint. + +## Result + +``` +$ npm run check # in template/ +COMPLETED 462 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS + +$ npm run smoke # at root +[smoke] /api/health: ok (project=shim) +[smoke] /api/list: ok (0 entries) +[smoke] GET /: ok (HTML returned) +[smoke] PASS + +$ curl -s http://127.0.0.1:15999/api/update-check +{"ok":true,"data":{"current":"0.1.11","latest":"0.1.11","hasUpdate":false}, + "cmd":"GET registry.npmjs.org/@forgeplan/web/latest"} + +$ curl -s http://127.0.0.1:15999/api/version +{"ok":true,"data":{"web":"0.1.11","cli":"0.27.0"},"cmd":"forgeplan --version"} +``` + +Vite build also reports `entries/endpoints/api/update-check/_server.ts.js +1.78 kB │ gzip: 0.83 kB`, confirming the new endpoint is bundled into +`dist/`. + +## Interpretation + +- **PRD-013 SC-1** (primitives exist + 1 widget imports): satisfied — + `template/src/widgets/version-footer/ui/UpdateDialog.svelte` imports + `Button`, `Code`, `Dialog` from `@/shared/ui`. +- **PRD-013 SC-2** (caller line count ≤ 3 lines to open a dialog): met + by the `modalManager.open(UpdateDialog, { current, latest })` call + inside `VersionFooter.svelte`. +- **PRD-013 SC-3** (update notice within ≤ 30 min of npm publish): + meets the upper bound by `THIRTY_MINUTES_MS` poll interval + + immediate `start()` on mount; live probe confirms the endpoint is + reachable and returns a well-formed envelope. +- **PRD-013 SC-4** (dialog renders manual update command): rendered by + `UpdateDialog.svelte` via ``. +- **PRD-013 SC-5** (endpoint is GET-only, respects rule 22): the only + HTTP method exported is `GET`; no `spawn` or `runForgeplan` is called + from `update-check/+server.ts`; URL is a string literal. +- **NFR-002** (endpoint never throws on registry failure): try/catch + wraps `getLatestCached`; failure path returns `{ ok: false, ..., + hasUpdate: false }`. Live probe with reachable registry returns the + success path; failure path is exercised structurally. +- **NFR-004** (zero new runtime deps): `git diff develop -- + template/package.json` shows no change in `dependencies`. + +The svelte-check pass over 462 files (up from 460 — the two new +modules) with zero errors and zero warnings means the new types +(ModalEntry, UpdateData, compareSemver) compose cleanly with existing +code. The smoke pass means `init` + `start` still work end-to-end after +the addition. The live curl proves the endpoint is wired. + +## Congruence Level Justification + +CL3 — the measurement is run against the exact files PRD-013 and +RFC-012 prescribe (same surface, same project, same commit). The smoke +test exercises the full ship-path (`bin/forgeplan-web.mjs init` → +`node dist/index.js` boot), and the curl probes the new endpoint by +its actual URL. evidence_type=test (binary pass/fail) + +verdict=supports (every assertion held). + +## Related Artifacts + +| Artifact | Relation | +| -------- | -------- | +| PRD-013 | informs | +| RFC-012 | informs | + + diff --git a/.forgeplan/prds/PRD-013-shared-ui-primitives-npm-update-notification.md b/.forgeplan/prds/PRD-013-shared-ui-primitives-npm-update-notification.md new file mode 100644 index 0000000..365ad73 --- /dev/null +++ b/.forgeplan/prds/PRD-013-shared-ui-primitives-npm-update-notification.md @@ -0,0 +1,290 @@ +--- +depth: standard +id: PRD-013 +kind: prd +last_modified_at: 2026-05-06T16:49:09.687333+00:00 +last_modified_by: claude-code/2.1.131 +status: draft +title: Shared UI primitives + npm update notification +--- + +--- +id: PRD-013 +title: "Shared UI primitives + npm update notification" +status: Draft +author: claude-code +created: 2026-05-06 +updated: 2026-05-06 +priority: P1 +depth: standard +domain: general +projectType: web_app +stepsCompleted: [] +--- + +# PRD-013: Shared UI primitives + npm update notification + +## Executive Summary + +### Vision + +Establish a minimal but reusable shared UI layer (Button, Code-with-copy, +Dialog) and a programmatic ModalManager so widgets can open dialogs without +manually mounting a component each time, then leverage that layer to surface +an "Update available" affordance in the version footer when a newer +`@forgeplan/web` is published on npm. + +### Problem + +Two coupled gaps in the current `template/`: + +1. There are no shared UI primitives. Every widget that needs a button, code + block, or dialog has to roll its own markup and CSS. There is no + programmatic way to open a modal — components must be instantiated and + mounted by every caller, which discourages reuse and bloats widget code. +2. Users have no way to learn that a newer `@forgeplan/web` is available. + The footer shows the running version but the user must check npm + manually. As a result, scaffolds drift behind the latest release for + weeks; bug fixes shipped in newer versions never reach users. + +**Impact**: New widgets needing a dialog (this PRD's update notice, plus +future settings/help modals) duplicate boilerplate. Stale `.forgeplan-web/` +installs miss CLI-CLI compatibility fixes and feature additions. + +### Target Users + +| Persona | Description | Key pain | +| ------------------ | -------------------------------------------------- | ------------------------------------------------------------------------ | +| Forgeplan author | Engineer running `npx @forgeplan/web start` daily | Doesn't know an update is available; widget code reinvents UI primitives | +| Template developer | Maintainer adding new widgets to `template/` | No shared Button/Dialog → reinvents markup, no consistent styling | + +### Differentiators + +- ModalManager is a programmatic, single-mount-point API (`modalManager.open(Component, props)`), + not a per-widget mount. +- Update check is read-only (HEAD/GET against npm registry), bounded + in frequency, and never auto-mutates the host. Manual update remains the + only path that touches `.forgeplan-web/`. + +--- + +## Success Criteria + +| ID | Criterion | Metric | Current | Target | Timeframe | How to Measure | +| ---- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- | ------------ | ------------ | --------------- | ----------------------------------------------- | +| SC-1 | Shared UI primitives Button/Code/Dialog exist under `template/src/shared/ui/` and are imported by ≥1 widget | Files exist + 1 widget imports | 0 primitives | 3 primitives | This PR | `ls template/src/shared/ui/` + grep for imports | +| SC-2 | Modal can be opened without mounting in caller — single `ModalRoot` mounted in root layout | Caller line count to open a dialog | N/A | ≤ 3 lines | This PR | Code review | +| SC-3 | When a newer `@forgeplan/web` is published, the user sees an Update affordance within ≤ 30 minutes | Time-to-notice from npm publish | ∞ (never) | ≤ 30 min | First polled tick post-publish | Manual: bump local pkg version, wait, observe footer | +| SC-4 | Update dialog shows current → latest and a copyable command; clicking copy writes the command to clipboard | Functional behaviour | N/A | Pass | This PR | Manual smoke in browser | +| SC-5 | npm-registry endpoint stays read-only and respects in-process concurrency cap | Endpoint method + spawn count | N/A | GET-only | This PR | Code review + `grep -RIn POST template/src/routes/api/update-check` returns 0 hits | + +--- + +## Product Scope + +### MVP (In-Scope) + +- `template/src/shared/ui/button/Button.svelte` — variants `primary`, + `secondary`, `ghost`; sizes `sm`, `md`; disabled state. +- `template/src/shared/ui/code/Code.svelte` — monospaced block with a copy + button; uses `navigator.clipboard.writeText` (with a textarea fallback for + insecure contexts that block the Clipboard API). +- `template/src/shared/ui/dialog/Dialog.svelte` — wraps `` + (HTMLDialogElement) with title, body slot, footer slot, close button, + ESC-to-close, scrim click to dismiss (toggleable per call). +- `template/src/shared/ui/modal/modalManager.svelte.ts` — exposes + `modalManager.open(component, props?)` returning a Promise that resolves + on close with optional return value; `modalManager.close(id?)`. +- `template/src/shared/ui/modal/ModalRoot.svelte` — single mount point + rendered once in `+layout.svelte`; iterates over `modalManager.stack`. +- `template/src/routes/api/update-check/+server.ts` — fetches + `https://registry.npmjs.org/@forgeplan/web/latest` via Node `fetch`, + returns `{ ok, data: { current, latest, hasUpdate } }`. +- Update poller — runs once at app mount, then every 30 min; shared + `createPoller` reused with `intervalMs: 1_800_000`. +- `UpdateButton` displayed above the version footer when `hasUpdate`; + click opens an `UpdateDialog` via modalManager. +- Documentation file describing modalManager usage ( + `template/src/shared/ui/modal/README.md`). +- Rule 22 amended to allow flag-only `--version` AND a single + non-forgeplan read-only endpoint (`/api/update-check`) hitting + `registry.npmjs.org`. + +### Out of Scope + +- Auto-update from the browser (clicking → `spawn npx @forgeplan/web update`). + Reason: rule 22 requires endpoints be read-only proxies; running `update` + mutates the host's `.forgeplan-web/` and would `rmSync` the very files + serving the request, crashing the running process. Out of scope for this + PR; revisit in a follow-up RFC if demand justifies the rule change. +- Dependency on third-party libraries for clipboard, modal, or button + (would inflate `dist/node_modules/` and break rule 21 purity goals). +- Toast / snackbar primitives (separate concern, not needed here). +- Theme tokens beyond what already exists in `template/src/app/styles/app.css`. + +### Growth Vision + +- Confirm/Alert convenience wrappers around modalManager (`modalManager.confirm("...")`). +- Settings dialog using the same modalManager. +- A future RFC may revisit auto-update via a detached spawn that exits the + running server gracefully. + +--- + +## User Journeys + +### Journey 1: Forgeplan author notices an update + +**Goal**: Discover that a newer `@forgeplan/web` is available and learn how to apply it. + +| Step | User action | System response | Notes | +| ---- | -------------------------------------------- | -------------------------------------------------------------------------------------- | ----- | +| 1 | Opens `http://127.0.0.1:5174` in a browser | App polls `/api/update-check` once at mount | | +| 2 | Server fetches npm registry, finds newer ver | Returns `{current: '0.1.11', latest: '0.1.12', hasUpdate: true}` | | +| 3 | UI shows a small "Update v0.1.12 →" button | Button rendered above the version footer (same corner) | | +| 4 | Clicks the button | modalManager opens UpdateDialog with current → latest + copyable `npx @forgeplan/web update` command | | +| 5 | Clicks "Copy" on the code block | Command copied to clipboard; visual confirmation | | +| 6 | Pastes in terminal, runs | Local `.forgeplan-web/` updated; user refreshes browser | | + +**Outcome**: User upgrades within minutes of becoming aware. + +### Journey 2: Template developer opens a dialog + +**Goal**: Open a settings dialog without mounting a component in their widget. + +| Step | User action | System response | +| ---- | ---------------------------------------------------------------------------- | -------------------------------------------------------------- | +| 1 | In a widget script: `import { modalManager } from '@/shared/ui/modal'` | Type-checked import | +| 2 | Calls `await modalManager.open(SettingsDialog, { initialTab: 'theme' })` | `ModalRoot` (mounted in `+layout.svelte`) renders the dialog | +| 3 | User dismisses; promise resolves | Caller receives optional return value | + +**Outcome**: Caller adds 3 lines, no per-widget `` markup. + +--- + +## Functional Requirements + +| ID | Category | Priority | Requirement | Journey | +| ------ | ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | --------- | +| FR-001 | UI | Must | Template developer can render a button via a single shared component supporting at least 3 visual variants and disabled state | Journey 2 | +| FR-002 | UI | Must | Template developer can render a copyable code block via a single shared component; the copy button writes the rendered text to clipboard | Journey 1 | +| FR-003 | UI | Must | Template developer can render a dialog with title, body, footer, ESC-close, and scrim-click-to-close (toggleable) via one shared component | Journey 2 | +| FR-004 | UI | Must | Template developer can open a modal programmatically by calling a single API method that takes a component and optional props | Journey 2 | +| FR-005 | UI | Must | Forgeplan author can see at most one Update affordance when a newer package version is published; affordance is hidden otherwise | Journey 1 | +| FR-006 | Integration | Must | Forgeplan author can read current and latest version side-by-side in the update dialog | Journey 1 | +| FR-007 | Integration | Must | Forgeplan author can copy the manual update command to clipboard with one click | Journey 1 | +| FR-008 | Polling | Must | System polls the registry once at app mount and at most once every 30 minutes thereafter while the tab is open | Journey 1 | +| FR-009 | Security | Must | System shall not mutate the host filesystem from any /api/update-check endpoint | Journey 1 | +| FR-010 | UX | Should | Template developer can stack modals (open from inside a modal) without losing the underlying one | Journey 2 | +| FR-011 | UX | Should | Forgeplan author can dismiss the update affordance for the session without losing the dialog content | Journey 1 | +| FR-012 | Docs | Should | Template developer can find a one-page how-to for modalManager in the repo | Journey 2 | + +--- + +## Non-Functional Requirements + +| ID | Category | Requirement | Metric | Condition | Measurement | +| ------- | ------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------- | ---------------------------------------- | ---------------------------------------------------------------------------- | +| NFR-001 | Performance | System shall respond to /api/update-check | < 500ms p95 | Cold cache, single client | Manual timing during smoke test | +| NFR-002 | Resilience | System shall return `{ok: false, error}` when the registry is unreachable, never throw an unhandled rejection | No 500s | Network error, DNS error, non-2xx status | curl -s /api/update-check after disabling network | +| NFR-003 | Security | System shall not pass user input to spawn or fetch URL construction | Static URL, no spawn | All requests | Code review: URL is a string literal, no `spawn` in update-check route | +| NFR-004 | Bundle | System shall add zero new runtime npm dependencies to `template/package.json` | Diff in `dependencies` | Post-merge | `git diff main -- template/package.json` | +| NFR-005 | Compatibility | System shall continue to work when registry returns no `latest` dist-tag (returns hasUpdate: false) | Graceful degradation | npm metadata edge case | Stub fetch with empty `dist-tags` | + +--- + +## Acceptance Criteria + +### AC-1: Update available — sticker shown + +```gherkin +Given the npm registry has @forgeplan/web@0.1.12 as latest dist-tag +And the running scaffold reports __FORGEPLAN_WEB_VERSION__ === "0.1.11" +When the SvelteKit app polls /api/update-check +Then the response contains hasUpdate: true and latest: "0.1.12" +And the UpdateButton renders above the version footer +``` + +### AC-2: No update — sticker hidden + +```gherkin +Given the npm registry latest dist-tag matches __FORGEPLAN_WEB_VERSION__ +When the app polls /api/update-check +Then the response contains hasUpdate: false +And no UpdateButton is rendered +``` + +### AC-3: Update dialog flow + +```gherkin +Given hasUpdate is true +When the user clicks the UpdateButton +Then modalManager opens an UpdateDialog containing: + - the current version (0.1.11) and latest version (0.1.12) + - a Code component with the literal text "npx @forgeplan/web update" + - a Copy action that writes that command to clipboard +``` + +### AC-4: Registry unreachable + +```gherkin +Given the npm registry is unreachable (DNS fail or non-2xx) +When the app polls /api/update-check +Then the endpoint returns { ok: false, error: "" } with HTTP 200 +And no UpdateButton is rendered +And the Svelte console logs no unhandled error +``` + +### AC-5: ModalManager API + +```gherkin +Given a Svelte component MyDialog +When a caller invokes modalManager.open(MyDialog, { foo: "bar" }) +Then the dialog appears in ModalRoot +And the call returns a Promise that resolves when the dialog closes +``` + +--- + +## Dependencies + +| Dependency | Type | Status | Owner | +| --------------------------------------- | -------- | ------ | ----------- | +| Existing `runForgeplan` server module | Internal | Ready | this repo | +| `__FORGEPLAN_WEB_VERSION__` Vite define | Internal | Ready | this repo | +| `registry.npmjs.org` HTTPS | External | Ready | npm Inc. | +| Rule 22 amendment | Internal | This PR | this repo | + +--- + +## Risks & Mitigations + +| ID | Risk | Probability | Impact | Mitigation | Owner | +| --- | ----------------------------------------------------------------------------------------------------------------- | ----------- | ------ | --------------------------------------------------------------------------------------------------------- | --------- | +| R-1 | npm registry rate-limits or returns 429 | Low | Low | 30-minute polling interval + graceful `hasUpdate: false` on non-2xx; cache result per server process | this repo | +| R-2 | navigator.clipboard fails on insecure context (older Safari) | Medium | Low | Fallback to a hidden `