From b9c98aa6323c5827e0e97195410ec140e0ed0ddb Mon Sep 17 00:00:00 2001 From: verlyn13 Date: Tue, 15 Sep 2026 19:17:24 -0800 Subject: [PATCH 1/4] docs: reconcile with the 2026-09 production release The first production release is complete, so the documentation that still described it as pending is now wrong. Reconcile the source-of-truth files against the observed provider state and drop the intermediate-state language. - status.md: release, stats projection and RLS actions closed; action 8 rewritten to the remaining legacy Worker boundary; new actions 16 and 17 for the Cloudflare token scope and the expiring Supabase CLI credential; readbacks replaced by current subjects, superseded rows dropped. - cloudflare.md: Pages removed as an origin and as a rollback path; the completed cutover runbook replaced by the platform constraints that stay true; live checks narrowed to the zone-route gap the scoped token cannot read; the zone redirects recorded for the first time. - roadmap.md: section 1 reordered around the work the release did not close; the Pages/OpenTofu field-boundary bullet retargeted at the surfaces that actually overlap now. - project.yaml, AGENTS.md, organization-alignment.md: phase, cutover and Worker-classification statements brought into line. - architecture/README.md: record the is_game_participant RLS-recursion invariant, which existed only in a migration header. - ws proxies: the frontend is the dicee-web Worker, not Pages. No infrastructure was touched. Live claims trace to operator readbacks; counts, names and HTTP status only. pnpm lint green (rust, analysis, biome, akg, cf:audit, scripts, docs). --- AGENTS.md | 2 +- docs/architecture/README.md | 1 + docs/cloudflare.md | 73 +++++---------- docs/development/organization-alignment.md | 14 +-- docs/roadmap.md | 44 +++++---- docs/status.md | 91 +++++++++---------- packages/web/src/routes/ws/lobby/+server.ts | 2 +- .../web/src/routes/ws/room/[code]/+server.ts | 2 +- project.yaml | 4 +- 9 files changed, 101 insertions(+), 132 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4966388..ed8f01f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ Before editing `packages/web` or `packages/cloudflare-do`, read that package's ` 3. Never expose or commit secrets, account identifiers, or project refs. Authorized Cloudflare operations run through `./scripts/with-dicee-cloudflare.sh -- `; Supabase operations use the Supabase CLI under explicit operator authority. Run `./scripts/check-1password-setup.sh` only for an explicitly authorized task that needs operator credentials. Infisical is retired: add no new Infisical usage; the remaining scripts are removed per `docs/roadmap.md` section 4. 4. Deployment, remote database writes, migrations, secret changes, destructive Git operations, and publication require explicit user authority. Dry runs and local validation are safe defaults. 5. Do not regenerate Supabase types as part of an ordinary local gate; that is an authenticated, live-schema operation. -6. Cloudflare work starts at `docs/cloudflare.md`. Committed architecture is the `dicee-web` Worker (SvelteKit, Workers Static Assets) with a `GAME_WORKER` service binding to the `dicee` Worker with SQLite Durable Objects, plus Supabase; moving `dicee.games` off the Pages project is an operator cutover. D1, R2, KV, further Worker splits, and OpenTofu are not current architecture; organization governance and infrastructure as code arrive only through `docs/roadmap.md`. +6. Cloudflare work starts at `docs/cloudflare.md`. Committed architecture is the `dicee-web` Worker (SvelteKit, Workers Static Assets) with a `GAME_WORKER` service binding to the `dicee` Worker with SQLite Durable Objects, plus Supabase; `dicee-web` holds the `dicee.games` custom domain and the Pages project is deleted, so web releases target `dicee-web` and there is no Pages rollback. D1, R2, KV, further Worker splits, and OpenTofu are not current architecture; organization governance and infrastructure as code arrive only through `docs/roadmap.md`. 7. Keep the legacy Durable Object `migrations` (v1 `GameRoom`, v2 `GlobalLobby`, both `new_sqlite_classes`); never edit or reorder an applied tag. Adopting declarative `exports` is a one-way door: only for a concrete need recorded in `docs/status.md`, as a standalone operator deploy. `pnpm cf:audit` enforces migrations mode. 8. Project MCP is minimal: `akg` (stdio) and unauthenticated `cloudflare-docs` are enabled; `cloudflare-api` and read-only `supabase` are opt-in OAuth servers. Never pass tokens through MCP config, command arguments, headers, credential-forwarding bridges, or bearer-token wrappers. An MCP session is not authority. 9. Canonical public URL: `https://dicee.games`. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index ba05d81..1a80291 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -180,6 +180,7 @@ A Durable Object has one native alarm. `AlarmQueue` multiplexes it under the `al - The functions are SECURITY DEFINER plpgsql executable only by `service_role`. `supabase/migrations/20260914000001_player_stats_projection.sql` holds their current definitions, and `supabase/tests/rpc_functions.sql` and `supabase/tests/player_stats_projection.sql` test them. - AI seats are `game_players` rows with `is_ai = true`, a NULL `user_id` and the AI profile in `ai_profile`. Seats are keyed by `(game_id, seat_number)`; completion matches human rankings by user id and AI rankings by seat number. An AI winner is stored as a NULL `winner_id`, and only events of human seats are persisted, because both columns reference profiles. - `player_stats` is a projection that clients only read. `rebuild_player_stats(user_id)` recomputes a row from completed games and `TurnScored` events, so repeats and retries give the same row. `complete_game_atomic` refreshes the game's human seats in its transaction, and `aggregate_game_stats` refreshes them again after the domain events land. +- RLS on `games` and `game_players` must never re-enter the `game_players` SELECT policy: a policy that queried it from both sides produced `infinite recursion detected in policy for relation game_players`. `is_game_participant(game_id)` is the SECURITY DEFINER, STABLE, empty-`search_path` helper that breaks that cycle, and every reading role holds EXECUTE on it because policies run as the caller. Any new policy on either table goes through the helper. `supabase/migrations/20260914000002_game_access_policies.sql` holds it. - Projection rules: a game counts when it is `completed` and the seat has a final score. A win is `final_rank = 1` in a game with more than one seat, AI seats included. Decisions are `TurnScored` events with a boolean `was_optimal`. - `PersistenceQueue` (`packages/cloudflare-do/src/lib/persistence/persistence-queue.ts`) stores tasks in the SQLite table `persistence_queue`. Task types are `PERSIST_GAME_COMPLETION`, `PERSIST_DOMAIN_EVENTS`, `TRIGGER_AGGREGATION` and `ABANDON_GAME`. - At game end, `GameRoom` queues the completion, the domain events and, 500 ms later, the stats aggregation. diff --git a/docs/cloudflare.md b/docs/cloudflare.md index 8734a8c..3e6294c 100644 --- a/docs/cloudflare.md +++ b/docs/cloudflare.md @@ -13,6 +13,8 @@ How Dicee runs on Cloudflare, what the committed configuration enforces, and how ```text browser + ├─ www.dicee.games, dicee.jefahnierocks.com, gamelobby.jefahnierocks.com + │ └─ zone Single Redirect 301 to https://dicee.games, same path, query preserved └─ https://dicee.games custom domain on the web Worker, the only public origin └─ Worker "dicee-web" packages/web: SvelteKit, adapter-cloudflare, Workers Static Assets ├─ Supabase Auth, Postgres, Storage (browser and SSR, direct) @@ -25,7 +27,9 @@ browser ``` - Two Workers on purpose: a deploy that changes Durable Object code disconnects every WebSocket and restarts the objects, so a UI-only release deploys `dicee-web` alone. -- The committed web configuration replaces the Pages project `dicee`. Which of the two serves `dicee.games` is live state; the [cutover](#cutover) moves the domain. +- `dicee-web` is the only web origin. The Pages project that used to serve `dicee.games` is deleted, so there is no Pages fallback and every web release targets `dicee-web` directly. +- A public hostname attaches to exactly one target at a time, so moving one is detach, then attach. A Worker custom domain needs an active zone on the account and cannot be created on a hostname that already carries a CNAME record. +- Three hostnames redirect at the zone rather than through a Worker: `www.dicee.games` inside the `dicee.games` zone, and `dicee.jefahnierocks.com` and `gamelobby.jefahnierocks.com` from the separate `jefahnierocks.com` zone, so the public surface spans two zones. Each is a proxied `A` record to an RFC 5737 documentation address, which is how a hostname gets proxied with no real origin behind it, and a Single Redirect rule sends it to `https://dicee.games` with the same path and the query preserved, status 301. They belong to neither Worker: never attach one as a `dicee-web` custom domain, and never restore the retired rule that prepended a `/games/dicee` prefix. - The browser only talks to the web origin. Every WebSocket URL builder uses `location.host`. - Requests that match a built asset are served by Workers Static Assets without running the Worker; every other request reaches SvelteKit. - Ten SvelteKit server routes proxy through `GAME_WORKER`: the WebSocket upgrades, the lobby APIs, transcription and the admin diagnostics routes. Their response helpers are in `packages/web/src/lib/server/ws-proxy.ts`. @@ -50,15 +54,15 @@ browser Wrangler configuration is the source of truth for both Workers' script settings. Before OpenTofu manages a resource that touches them (a custom domain, a Worker setting), require an agreed field map and prove import/no-op behavior, an authorized ordinary Wrangler release, and a subsequent infrastructure plan without unintended resets. Leave overlapping fields unmanaged by OpenTofu if the pinned provider cannot preserve this boundary. Do not use broad drift-ignore rules as proof of ownership. -**Credentials and immediate recovery.** The exposed Cloudflare token was replaced through the existing 1Password wrapper and GitHub Production secret path, and the old token verified dead ([status readbacks](status.md#latest-live-readbacks)). Today one account-owned deploy token (Pages Write, Workers Scripts Write and Account Settings Read across the whole account) serves both CI and the local wrapper; MCP uses OAuth instead. Both Workers release with Workers Scripts Write; Pages Write is needed only until the Pages project is deleted. The target design separates inventory/plan readers, infrastructure apply, application release and local operator consumers, with distinct environments where supported. Verify effective permissions and record residual account-wide reach: token names and directories do not enforce per-script isolation. The permissions for [Workers Scripts](https://developers.cloudflare.com/fundamentals/api/reference/permissions/) are account-scoped; issuing separate tokens improves attribution and revocation without proving resource isolation. Token ownership/type and exact capabilities must be established before choosing each replacement. +**Credentials and immediate recovery.** The exposed Cloudflare token was replaced through the existing 1Password wrapper and GitHub Production secret path, and the old token verified dead ([status readbacks](status.md#latest-live-readbacks)). Today one account-owned deploy token (Pages Write, Workers Scripts Write and Account Settings Read across the whole account) serves both CI and the local wrapper; MCP uses OAuth instead. Both Workers release with Workers Scripts Write. With the Pages project deleted, Pages Write has no consumer left, so until [status action 16](status.md#open-operator-actions) removes it the token's reach is wider than anything this repository uses. The target design separates inventory/plan readers, infrastructure apply, application release and local operator consumers, with distinct environments where supported. Verify effective permissions and record residual account-wide reach: token names and directories do not enforce per-script isolation. The permissions for [Workers Scripts](https://developers.cloudflare.com/fundamentals/api/reference/permissions/) are account-scoped; issuing separate tokens improves attribution and revocation without proving resource isolation. Token ownership/type and exact capabilities must be established before choosing each replacement. -**State and environments.** Keep `dicee.games`, the `dicee` Worker name, class names and binding interfaces; the web release target is `dicee-web`. `dicee-production` remains unclassified. Before any `dicee` release, verify both namespace owners; account relocation cannot assume namespace/data continuity. Keep the default production deployment rather than introducing a named production environment. There is no hosted preview; staging waits for its roadmap trigger and a complete backend/data/credential boundary. +**State and environments.** Keep `dicee.games`, the `dicee` Worker name, class names and binding interfaces; the web release target is `dicee-web`. `dicee` and `dicee-web` are the only Workers this repository deploys; `dicee-production`, `gamelobby` and `gamelobby-production` are classified legacy scripts awaiting deletion (see Hard stops). Before any `dicee` release, verify both namespace owners; account relocation cannot assume namespace/data continuity. Keep the default production deployment rather than introducing a named production environment. There is no hosted preview; staging waits for its roadmap trigger and a complete backend/data/credential boundary. ## Configuration **Game Worker** (`packages/cloudflare-do/wrangler.jsonc`): -- Name `dicee`, entry `packages/cloudflare-do/src/worker.ts`, `compatibility_date` 2026-07-21, flag `nodejs_compat`. +- Name `dicee`, entry `packages/cloudflare-do/src/worker.ts`, `compatibility_date` 2026-07-21, flag `nodejs_compat`. A bump is a deliberate change under status decision 5. - `workers_dev: false` and `preview_urls: false`, set at the top level and never overridden. - Durable Object lifecycle: the legacy `migrations` array, v1 `GameRoom` and v2 `GlobalLobby`, both `new_sqlite_classes`. It is declared once at the top level and inherited (status decision 1). - Bindings `GAME_ROOM`, `GLOBAL_LOBBY` and `AI`; var `ENVIRONMENT`, which nothing reads (see Open decisions). @@ -73,7 +77,7 @@ Wrangler configuration is the source of truth for both Workers' script settings. - Exactly one service binding, `GAME_WORKER` to `dicee`. No vars, secrets, storage, AI or Durable Object bindings. - Observability: logs at head sampling 1. - No `run_worker_first`: assets serve first. The adapter's generated `_headers` gives `/_app/immutable/*` long-lived caching. `_headers` and `_redirects` rules never apply to Worker-generated responses, so security headers come from `packages/web/src/hooks.server.ts` and CSP from `kit.csp` in `packages/web/svelte.config.js`. -- The web build needs `PUBLIC_SUPABASE_URL` and `PUBLIC_SUPABASE_ANON_KEY` at build time (`$env/static/public`). The CI `deploy-web` job reads them from the Production environment. `packages/web` holds no service-role material. +- The web build needs `PUBLIC_SUPABASE_URL` and `PUBLIC_SUPABASE_ANON_KEY` at build time (`$env/static/public`). The CI `deploy-web` job reads the first from a Production environment variable and the second from a repository secret, then fails the job if either is empty. They come from different stores, so a change to one does not move the other. `packages/web` holds no service-role material. **Generated types.** Each package commits a generated `worker-configuration.d.ts`: @@ -102,13 +106,14 @@ Wrangler configuration is the source of truth for both Workers' script settings. - **Web Worker:** no `durable_objects`, `d1_databases`, `r2_buckets`, `kv_namespaces`, `ai`, `exports`, `queues` or `migrations`. - Exactly one service binding per block, targeting a Worker name the backend declares, and a `name` that is none of the backend's Worker names. - `workers_dev` and `preview_urls` explicitly false; no `route`, `routes` or `custom_domain`. - - Workers Static Assets shape: `main`, `assets.directory` and `assets.binding` set, no `pages_build_output_dir`, no single-page-application not-found handling. A named environment bound to the production backend warns. + - Workers Static Assets shape: `main`, `assets.directory` and `assets.binding` set, no `pages_build_output_dir`, no single-page-application not-found handling (it would serve the shell in place of SSR). A named environment bound to the production backend warns. - **Both:** no account id or API token literal, and no legacy TOML config beside `wrangler.jsonc`. Hard stops. These are owner decisions, never config tweaks: - **No `exports` key.** Never edit, reorder or remove the applied v1/v2 tags. `exports` is one-way: no return to `migrations`, no rollback across the change, no gradual deploy. It is adopted only for a concrete need recorded in status, as a standalone operator deploy. - **No Worker or Durable Object class rename.** Namespaces are keyed to the script name, so a renamed Worker starts with empty namespaces. A class rename needs its own lifecycle step. +- **Deleting a Worker deletes its Durable Object namespaces.** The classified legacy scripts ([status action 8](status.md#open-operator-actions)) are `gamelobby`, which owns none and is the lowest-risk deletion, and `dicee-production` and `gamelobby-production`, which each own a SQLite `GameRoom`/`GlobalLobby` pair; `gamelobby-production` also still exposes `workers.dev` and Preview URLs. Delete one at a time, after the dashboard route read under [Live checks](#live-checks), verify the public app after each, and never force-delete a namespace-owning script without an explicit decision to destroy that state. - **No ingress on `dicee`.** No `route`, `routes`, `custom_domain` or `workers_dev: true` on the game Worker. - **The web Worker never uses a game Worker name.** Deploying the web config as `dicee` would replace the game Worker's code and bindings. - **No backend bindings on the web Worker.** No Durable Object, storage, D1, R2, KV, AI or queue bindings in `packages/web/wrangler.jsonc`. @@ -126,9 +131,9 @@ Safe without deploy authority: `wrangler types`, `types:check`, `wrangler deploy 2. `deploy-worker`: the Production environment, a `production-deploy` concurrency group that is never cancelled, builds `@dicee/shared`, then `wrangler deploy --env=""`. 3. `deploy-web` ("Deploy web Worker"): reuses the validated WASM artifact, builds shared and web, then runs `wrangler deploy` for `dicee-web`. -`deploy-web` needs `deploy-worker`, so a CI dispatch always redeploys `dicee` as well, which restarts its Durable Objects. The next release follows [roadmap section 1](roadmap.md#1-safety-now) (status action 3): live checks 1-2 first, then `dicee` becomes the one backend even when that resets live rooms held by another script (status decision 1). Review the exact release configuration and migrations as well; ownership is a deployment prerequisite, not approval of every later artifact. +`deploy-web` needs `deploy-worker`, so a CI dispatch always redeploys `dicee` as well, which restarts its Durable Objects and drops every live socket; dispatch at a quiet time, or take the web-only path below when nothing under `packages/cloudflare-do` changed. The Production reviewer approves the artifact in front of them, not every later one: review the release commit, the Wrangler configuration and any pending database migration before approving. -A web-only release uses the operator-local `pnpm web:deploy` and leaves `dicee` untouched. `GAME_WORKER` resolves `dicee` by name, so it is safe only when live check 1 shows `dicee` is the live backend. Use a clean checkout of the exact successful CI commit and read the local web build environment warning below. +A web-only release uses the operator-local `pnpm web:deploy`, leaves `dicee` untouched and is the default for a UI-only change: `GAME_WORKER` resolves the live `dicee` Worker by name and that binding does not change. Use a clean checkout of the exact commit that passed CI and read the local web build environment warning below. **Operator escape hatches** skip the validation gate and need explicit authority: @@ -143,56 +148,29 @@ A web-only release uses the operator-local `pnpm web:deploy` and leaves `dicee` **Known hazards:** - **Two Worker versions per dispatch.** The Cloudflare wrangler-action step uploads its `secrets` input with `wrangler secret bulk` before it runs `deploy`, and a secret upload creates and deploys a version. For a short window new secrets run on old code. `wrangler deploy --secrets-file` is the single-operation form. -- **Empty namespaces on the wrong script.** A `migrations` deploy is a lifecycle no-op only when the target script already carries tag v2. Otherwise it provisions empty namespaces and live state stays on the old script. Know which case applies from live check 1 before deploying; an intended cutover to `dicee` accepts that reset (status decision 1). +- **Empty namespaces on the wrong script.** A `migrations` deploy is a lifecycle no-op only when the target script already carries tag v2. `dicee` does; any other script name provisions empty namespaces and leaves live state behind, so never deploy this config under a different Worker name. - **First deploy to a new game Worker name.** With `secrets.required` set, it needs `--secrets-file`. `dicee-web` declares no secrets. -- **Two resources named `dicee`.** The Pages project and the game Worker share a name in separate namespaces. Cutover cleanup deletes the Pages project, never the Worker. -- **No rollback in CI.** Rollback is operator-run (`wrangler rollback` per Worker) and cannot cross a Durable Object lifecycle change. Roll forward by default. +- **No rollback in CI, and no Pages fallback.** Rollback is operator-run, `wrangler rollback` per Worker, and cannot cross a Durable Object lifecycle change; it replaces the Worker's code, never the custom domain, which stays on `dicee-web` either way. The Pages project that once served the domain is deleted, so recovery is a Worker version rollback or, by default, rolling forward. - **No staging path.** Production is the first environment a change reaches. **Post-deploy smoke** (operator): - sign-in, which reaches the production Supabase project (a locally built deploy can inline local values); - lobby and room WebSocket upgrades; +- the unauthenticated lobby APIs `/api/lobby/rooms` and `/api/lobby/online` return JSON, which proves `dicee-web` -> `GAME_WORKER` -> `dicee` -> `GlobalLobby`; - security headers and CSP, with the WASM engine loading in a Chromium browser; - `/_app/immutable/` assets load, and an unknown path returns the SvelteKit 404 page; - transcription; - admin pages refuse a non-admin session. -### Cutover +## Live checks -Moving `dicee.games` from the Pages project to `dicee-web` is a one-time operator release step with a short outage. Platform constraints: +Use read-only methods only: the dashboard or a read-scoped API `GET`. Record each result in the [status.md](status.md) readbacks as names, counts and HTTP status. Public product hostnames may be named; account, zone and namespace ids, the account `workers.dev` subdomain, project refs and secret values never may be. -- A Worker custom domain needs an active Cloudflare zone the account owns, and cannot be created on a hostname with an existing CNAME record. -- A hostname is attached to one target at a time: detach it from the Pages project, then attach it to `dicee-web`. -- `www` to apex stays a zone redirect rule; it belongs to neither project. -- `_redirects` is not applied to Worker-served requests; the repository ships none. +- **Zone-level Worker routes** are the one surface the scoped Cloudflare credential cannot read: those reads return HTTP 403. Read them in the dashboard, under each script's Domains & Routes, before deleting any legacy script; do not broaden the credential to close the gap. +- **Immediately before a deletion or a release**, re-read the target's routes, custom domains, direct Worker URLs and secret names. `wrangler secret list` returns names only. An inventory older than the change is not evidence for it. -Steps, each with explicit authority: - -1. Live checks 1, 2 and 6 show `dicee` as the live backend, the current web origin and an active zone. -2. From the exact validated commit with production public values, run the web dry run, then `pnpm web:deploy`. The new Worker has no public hostname yet. -3. Detach `dicee.games` from the Pages project, confirm no CNAME remains on the apex, and attach it to `dicee-web` as a Custom Domain. Deploy `dicee` only after this step, with `pnpm do:deploy` rather than a CI dispatch (which deploys `dicee` first): its protocol gate closes the old Pages client's sockets with code 4426. -4. Run the post-deploy smoke against `https://dicee.games` and confirm the `www` redirect. Until step 5, rollback is detach from the Worker and reattach to Pages. -5. Delete the Pages project `dicee`, then remove Pages Write from the deploy token. - -## Live checks still needed - -Use read-only methods only: the dashboard or a read-scoped API `GET`. Record each result in the [status.md](status.md) readbacks as names, counts and HTTP status. Never record account, zone or namespace ids, subdomains, project refs or secret values. - -1. **Namespace owner.** Which Worker scripts hold the live `GameRoom` and `GlobalLobby` namespaces (class, script, SQLite), their migration tags where a read-only source shows them, and the deployed versions. Method: the namespace's Deployments tab in the dashboard, or a read-scoped `GET` of the account's Durable Object namespace list. The result decides between a no-op lifecycle deploy to `dicee` and a cutover to it (status decision 1); stop only on ambiguous ownership. -2. **Current web origin.** Whether `dicee.games` is attached to the Pages project or to `dicee-web`, whether `dicee-web` exists, and, while Pages serves, what its production `GAME_WORKER` targets. Method: dashboard domain settings, or `wrangler pages download config` into a private scratch directory outside the repository (never with `--force`). A different or unknown backend target requires reconciliation before any release. -3. **Subdomains.** Done for `dicee` and `dicee-production` (status action 6); repeat for `dicee-web` and any other Dicee Worker script that check 4 finds. Method: each script's domains and routes settings in the dashboard. -4. **Obsolete surfaces.** Routes, custom domains and last deployment on every Dicee Worker script other than `dicee`, and on every Pages project, classified for deletion (status action 8). -5. **Secret names.** Secret names on each script, and on the Pages project until it is deleted; `wrangler secret list` and `wrangler pages secret list` return names only. -6. **Zone and redirect.** Whether `dicee.games` is an active zone on this account, the record types for the apex and `www` (a Worker custom domain cannot be created on a hostname with an existing CNAME record), whether Registrar is used, and where the www-to-apex redirect rule lives. This gates the cutover and feeds the organization move. - -Method safety: - -- `wrangler secret put`, `bulk` and `delete` create and deploy a version. -- `wrangler triggers deploy` changes routes. -- `pages download config` writes a file. - -None of these is a readback. For reachability use the Worker's `/health`; do not probe admin diagnostics paths. +Method safety: `wrangler secret put`, `bulk` and `delete` each create and deploy a version, and `wrangler triggers deploy` changes routes. None of these is a readback. For reachability use the public site and the lobby APIs; `dicee` has no public ingress, and admin diagnostics paths are never a probe. ## Open decisions @@ -200,19 +178,12 @@ Each open decision has a recommended default. The owner decides, and [status.md] - **Preview shares the production Worker** (audit warning F7). Decided: no hosted preview backed by production (status decision 8). The web Worker has no named environment, `workers.dev` subdomain or Preview URLs; local `pnpm dev:full` is the test path, and hosted multiplayer testing waits for an isolated backend (roadmap section 8). - **Room storage retention.** Nothing deletes Durable Object storage, so finished rooms keep theirs. Default: reclaim storage when a room is finished and empty, on the existing alarm path (roadmap, Worker correctness). Plan as though SQLite storage is billed; the account billing view is the evidence. -- **`secrets.required` after the Supabase key migration.** Default: one secret key replaces the legacy anon and service-role names, and the Worker sends it only as `apikey`. Change the list, the code and the CI secrets together, and set the new secret on the Worker before the deploy that requires it. +- **`secrets.required` after the Supabase key migration.** Default: one secret key replaces the legacy anon and service-role names, and the Worker sends it only as `apikey`. Change the list, the code and the CI secrets together, and set the new secret on the Worker before the deploy that requires it. Until then the Worker secrets and the web build's `PUBLIC_SUPABASE_ANON_KEY` stay on the legacy anon key: never swap a stored secret to the newer publishable format on its own. - **`SUPABASE_JWT_SECRET` read but undeclared** (audit warning B8). Default: resolve it by removing HS256, never by adding the name. - **Unused `ENVIRONMENT` var** (audit warning B11). Removed from the web config. Default: delete it from the game Worker config unless code starts reading it. - **Named `development` and `staging` environments.** No CI job or binding uses them. Default: keep them until the staging trigger fires, then either wire one into CI or delete both. - **Infrastructure adoption details** (status decision 9). The [governance strategy](#governance-strategy) selects OpenTofu/Wrangler responsibilities and four credential consumers. The exact repository/root, backend/state controls, pinned provider field map and effective token reach remain open. Acceptance and the import/release/plan checks precede infrastructure writes; a dedicated account remains a separate conditional migration decision. -Direction (status decision 8): serve the web app from `dicee-web` on Workers Static Assets and keep `dicee` unchanged. Pitfalls to keep in view: - -- never use single-page-application not-found handling with SSR (the audit rejects it); -- assets serve before the Worker unless `run_worker_first` is set, so hooks never see asset requests; -- `_headers` and `_redirects` never apply to Worker-generated responses; -- a domain cutover is detach, then attach. - ## Local commands ```sh diff --git a/docs/development/organization-alignment.md b/docs/development/organization-alignment.md index e811b9a..1d07b43 100644 --- a/docs/development/organization-alignment.md +++ b/docs/development/organization-alignment.md @@ -32,9 +32,9 @@ The local agent roles are **Builder**, **Maintainer**, **Reviewer** and **Operat | GitHub repository | Current home: `jefahnierocks/dicee`; transfer completed 2026-09-14. | | Local checkout | Current home: `~/Organizations/jefahnierocks/dicee/`, as an independent Git repository. This existing monorepo needs no internal reshuffle. | | Project manifest | Keep project id `dicee`; owner is now `jefahnierocks`. Preserve the manifest's existing schema and status home. | -| Web Worker | `dicee-web` serves the SvelteKit app (status decision 8); the Pages project `dicee` is deleted after the cutover. A Pages project name and a Worker script name identify different resources. | -| Default production Worker | Source targets `dicee`; preserve that name pending the live namespace-owner check. | -| Other existing Worker | `dicee-production` is unclassified. Its suffix proves neither its role nor that it is safe to remove. | +| Web Worker | `dicee-web` serves the SvelteKit app (status decision 8) and holds the `dicee.games` custom domain; the Pages project `dicee` is deleted. A Pages project name and a Worker script name identify different resources. | +| Default production Worker | `dicee` is the confirmed owner of the live `GameRoom` and `GlobalLobby` namespaces (status action 5); preserve the name. | +| Other existing Worker | `dicee-production` is a classified legacy deletion candidate (status action 8), alongside `gamelobby` and `gamelobby-production`. Its suffix proves neither its role nor that it is safe to remove. | | Runtime interfaces | Keep `GAME_WORKER`, `GAME_ROOM`, `GLOBAL_LOBBY`, `AI`, `GameRoom` and `GlobalLobby`. These are application interfaces, not places to add organization prefixes. | | Future environments | Use explicit, consistent environment labels. A candidate staging script is `dicee-staging`; check the name against live inventory before choosing it. Do not introduce a named production environment merely to standardize spelling. | @@ -99,14 +99,14 @@ The first infrastructure adoption should aim to describe existing resources with ## Runtime and environment boundaries to preserve -The shape is browser → `dicee-web` Worker → `GAME_WORKER` → `dicee` Worker → SQLite Durable Objects, with Supabase for Auth/Postgres/Storage; status decision 8 moved the web app off Pages for platform reasons, not for alignment. The public application origin remains `dicee.games`; `dicee` is reachable only through the service binding. Do not add a direct game-Worker hostname, Tunnel, Access application, D1, R2, KV or Queues merely for organizational alignment. Such additions need their own product or security reason. +The shape is browser → `dicee-web` Worker → `GAME_WORKER` → `dicee` Worker → SQLite Durable Objects, with Supabase for Auth/Postgres/Storage; status decision 8 moved the web app off Pages for platform reasons, not for alignment, and that move is complete. The public application origin remains `dicee.games`; `dicee` is reachable only through the service binding. Do not add a direct game-Worker hostname, Tunnel, Access application, D1, R2, KV or Queues merely for organizational alignment. Such additions need their own product or security reason. Preserve the project's existing hard stops: -- Before any `dicee` deployment, read back which script owns the live `GameRoom` and `GlobalLobby` namespaces. `dicee` is the one game backend (status decision 1): a cutover from another script accepts a live-state reset, and obsolete scripts such as `dicee-production` are deleted once classified. +- Before any `dicee` deployment, read back which script owns the live `GameRoom` and `GlobalLobby` namespaces. `dicee` is the one game backend (status decision 1) and the confirmed owner; obsolete scripts such as `dicee-production` are deleted once classified, one at a time. - Preserve applied v1/v2 `new_sqlite_classes` migrations. Do not rename a Worker/class, introduce a new namespace or switch to declarative `exports` as a naming cleanup. Lifecycle work remains a separate operator change with a state-preservation plan. - A new Worker script or account is not a transparent move of existing data. Cloudflare class-transfer migrations move namespaces between Worker scripts in the same account; do not create the destination class first and then expect a later transfer to preserve the old namespace. Account relocation requires a separate migration/recovery design and must not assume namespace or data continuity. See [legacy class transfers](https://developers.cloudflare.com/durable-objects/reference/durable-object-class-migrations-legacy/#transfer-migration). -- Inventory all ingress: Worker subdomains, version Preview URLs, routes, custom domains, Pages domains until the Pages project is deleted, and binding targets. The reported disabling of Worker subdomains does not prove the other paths absent. Keep sensitive admin authorization enforced in application code as well as the intended ingress design. +- Inventory all ingress: Worker subdomains, version Preview URLs, routes, custom domains, the zone redirect rules in both the `dicee.games` and `jefahnierocks.com` zones, and binding targets. The reported disabling of Worker subdomains does not prove the other paths absent. Keep sensitive admin authorization enforced in application code as well as the intended ingress design. | Environment | Current source shape | Adoption intention | |---|---|---| @@ -159,7 +159,7 @@ Use the existing roadmap, not a second phase system. Its credential prerequisite For the organization-move item, prepare one reviewable intake that answers: 1. **Ownership and names:** intended GitHub/local home, accepted Jefahnierocks project contract, service owner, infrastructure root, shared-account steward, credential consumers and the disposition of both existing scripts. -2. **Fresh inventory:** Pages domains until deletion, Worker routes/subdomains and bindings, namespace owners, secret names and token reach; zone/registrar/redirect ownership; GitHub Apps, Actions configuration and each protection surface. Record unknowns explicitly and keep private identifiers out of the repository. +2. **Fresh inventory:** Worker routes/subdomains, custom domains and bindings, namespace owners, secret names and token reach; zone/registrar/redirect ownership across both zones; GitHub Apps, Actions configuration and each protection surface. Record unknowns explicitly and keep private identifiers out of the repository. 3. **Transfer continuity:** repository protections and integrations before/after transfer; updates to the remote, manifest and repository references already named by the roadmap; preservation of public URLs and application identity. Do not recreate the old GitHub repository path after transfer. 4. **Infrastructure boundary:** accepted resource/field ownership, protected state location, pinned toolchain, credential split, source validation, import/no-op plan, first-write authority and readback. Infrastructure adoption and any later account move have separate acceptance evidence. 5. **State and recovery:** no accidental namespace creation or Worker rename; a resource-appropriate recovery/cutover plan before account moves or destructive cleanup; legacy scripts deleted once their ingress and consumers are classified. diff --git a/docs/roadmap.md b/docs/roadmap.md index 2806685..b5cc7db 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,31 +4,34 @@ Ordered next work. State, decisions and deadlines live in [status.md](status.md) ## 1. Safety now -The backup, profile-role hardening/audit, two-script Worker URL restrictions and credential containment are recorded as complete in [status](status.md#latest-live-readbacks). Status action numbers are stable references; this is the execution order. Preserve durable facts (Supabase data, migrations, backups), not obsolete runtime topology: fix forward, delete classified legacy surfaces, and make the next production release the architecture we keep. Each production step retains its operator authority and stop points. +The first production release is done: `20260914000001` and `20260914000002` are applied and recorded, `player_stats` was rebuilt once, `dicee-web` serves `dicee.games` over the `GAME_WORKER` binding to `dicee`, and the Pages project is deleted. [Status](status.md#latest-live-readbacks) holds the evidence; do not restate it here. Status action numbers stay stable references; what follows is the execution order for the work the release did not close, and each production step keeps its operator authority and stop points. -Action 5 discovery and action 10 GitHub governance are complete. Action 8 retains a deletion-time zone DNS/Worker-route check because the scoped Cloudflare token cannot read those surfaces; that gap does not block the first release. +Two release-time assumptions are retired. Reattaching a hostname to Pages is no longer a rollback path: roll forward, and target `dicee-web` for every web release. There is no deployed `aggregate-game-stats` Edge Function to delete; the projection refresh is `aggregate_game_stats(uuid)` in the database. The scoped Cloudflare credential still returns HTTP 403 for zone-level Worker route reads; close that gap with a dashboard read before each deletion, never by widening the token. -1. First release (action 3) from a clean checkout of the exact successful CI commit, at a quiet time, with operator commands rather than a CI dispatch: read back the completed `games` count, `player_stats` rows and summed `games_played` (the one-time rebuild resets stats that no completed game backs), apply `20260914000001` and `20260914000002` alone, deploy `dicee-web`, detach `dicee.games` from Pages and attach it to `dicee-web` (short outage), then deploy `dicee` (its protocol gate closes the old Pages client's sockets), run the smoke checks including one completed game, delete the deployed `aggregate-game-stats` Edge Function and rebuild all `player_stats` once, then delete the Pages project and the classified scripts (action 8) — sign-in, room/lobby, a persisted game with stats, headers, transcription, non-admin refusal and deletion readbacks. -2. Implement the profile visibility opt-in control, initially off for private profiles, writing `profiles.is_public`; explain that visibility is voluntary and cover it with tests. Merge through the new ruleset — successful full validation on the PR. -3. Deploy the opt-in code, verify the control and a test bug report, then take a fresh complete encrypted backup. Recheck the production link and history (`000001` remote, `000002` local-only), apply only `000002`, and verify the schema and two-account privacy behavior (action 4). Invite opt-ins only after verification; the migration clears earlier opt-ins. Fix forward — fresh backup evidence, migration readback and privacy tests. +1. Delete the obsolete legacy Workers (action 8) one at a time, each preceded by a dashboard read of that script's Domains & Routes. Take `gamelobby` first — no Durable Object namespaces, no custom domains, no service-binding consumers — with an ordinary non-force deletion. `dicee-production` and `gamelobby-production` are separate decisions: both still own SQLite `GameRoom`/`GlobalLobby` namespaces, so force-deleting either destroys that legacy state, and `gamelobby-production` also still exposes `workers.dev` and Preview URLs, which is live public ingress on a retired script — zero Worker Routes shown for the script, then a deletion readback and a green public smoke. +2. Narrow the Cloudflare deploy/operator credential (action 16), independently of the deletions above — a capability readback showing the reduced scope, confirmed functionally by the next authorized release. +3. Implement the profile visibility opt-in control, initially off for private profiles, writing `profiles.is_public`; explain that visibility is voluntary and cover it with tests. Merge through the ruleset — successful full validation on the PR. +4. Deploy the opt-in build, verify the control and a test bug report, then take a fresh complete encrypted backup. Recheck the production link and migration history, apply only `20260913000002`, and verify the schema and two-account privacy behavior (action 4). Invite opt-ins only after verification; the migration resets every profile to private and clears earlier opt-ins. Fix forward — fresh backup evidence, migration readback and privacy tests. -Do not reapply or reverse `000001`, run a broad database push, or treat a successful dry run as namespace proof. Hosted multiplayer testing waits for an isolated backend (section 8). +Do not reapply or reverse an applied migration, run a broad database push, or treat a successful dry run as namespace proof. Hosted multiplayer testing waits for an isolated backend (section 8). ## 2. Supabase obligations -Date-driven and independent of the organization move. Agents write and test the migrations locally; the operator applies them before the external dates. +Date-driven and independent of the organization move. `20260913000001`, `20260914000001` and `20260914000002` are applied; `20260913000002` is section 1 work. Agents write and test the migrations locally; the operator applies them before the external dates. - Explicit per-table grants and default-privilege revokes for tables and sequences, with a pgTAP privilege matrix — `supabase db reset --local && supabase test db`; operator applies before 2026-10-30. - Secretless CI lane that runs a local `supabase db reset` and `supabase test db` — green on a PR. +- Re-set the GitHub `PUBLIC_SUPABASE_ANON_KEY` repository secret to the current canonical legacy anon key through the approved delivery path. The release used that key and verified it functionally, but the stored CI value was never read back, so a CI dispatch remains unproven — a `workflow_dispatch` web deploy that reaches production sign-in. This is CI confidence on the legacy key, deliberately not the migration below. - API key migration: web to a publishable key; decide the Worker's key shape ([open decision](cloudflare.md#open-decisions); default: one secret key on the `apikey` header only, dropping the Bearer header in `packages/cloudflare-do/src/lib/persistence/supabase-rpc.ts`), then implement it, update `secrets.required`, run `pnpm types` and rename the CI secrets — tests and dry run; operator deactivates legacy keys before end-2026. - Asymmetric JWT signing: decide how to clear audit warning B8 ([open decision](cloudflare.md#open-decisions); default: remove HS256), then pin the JWKS verification algorithms and remove the HS256 fallback, `SUPABASE_JWT_SECRET` and the trailing `packages/cloudflare-do/wrangler.jsonc` comment that asks for it — auth tests; operator confirms the signing-key state first. - Minimize Supabase after a row-count readback and a fresh dump: drop vestigial tables, RPCs and columns (gallery, `solo_leaderboard`, `rooms`, `analysis_events`, `feature_flags`, spectator policies, Glicko and badge columns) with their TypeScript; keep `log_admin_action` as the only admin audit writer; close the `bug_reports` delete-policy gap — pgTAP green; operator applies. -- Residual policy fixes for whatever minimization keeps: the open read policies on `admin_permissions` and `feature_flags`, `SET search_path` on the gallery security-definer functions, and spectator policies that match a `playing` status the `games` check never allows — pgTAP green; operator applies. +- Keep the stats projection out of that sweep: `rebuild_player_stats(uuid)`, `refresh_player_stats_for_game(uuid)` and the `aggregate_game_stats(uuid)` wrapper the Worker still calls are current, and `update_category_stats` is already dropped — do not relist it. +- Residual policy fixes for whatever minimization keeps: the open read policies on `admin_permissions` and `feature_flags`, `SET search_path` on the gallery security-definer functions, and the spectator branch of the live `games` and `game_players` SELECT policies, which matches a `playing` status the `games` check constraint never allows. A rewrite preserves `is_game_participant(uuid)` and the non-recursive shape `20260914000002` established — pgTAP green; operator applies. - Persistence follow-ups: the Worker's `TurnScored` events carry `was_optimal` and `ev_difference`, so Decision Quality stops reading 0; scheduled abandonment, schema-validated outbox rows and surfaced permanent failures — tests. ## 3. Worker correctness and security -Agent-safe to build. Worker deploys wait for status action 5. +Agent-safe to build. Shipping any of it is an ordinary authorized release, not a cutover: a `dicee` deploy restarts its Durable Objects and closes live sockets, so batch these items and release at a quiet time. - Move admin diagnostics behind the service binding with in-Worker authorization; lobby room removal moves from `setTimeout` to an alarm — lobby auth tests and dry run. - GameRoom: alarm reconciler, alarm-based invite and join expiry, admission in `onConnect` (private rooms admit only invited players), a decision on finished-room storage retention ([open decision](cloudflare.md#open-decisions); default: reclaim when finished and empty) and its implementation, and the structured logger in place of ad hoc `console.error` — `vitest run src/lib src/game` in `packages/cloudflare-do`. @@ -41,18 +44,19 @@ Agent-safe to build. Worker deploys wait for status action 5. Agent-safe unless noted. -- Retire the Infisical scripts, metadata names and .infisical.json handling — `rg -i infisical scripts .gitignore` is empty; operator revoked the identities (status action 13). +- Retire the Infisical scripts, metadata names and .infisical.json handling — `scripts/with-dicee-infisical-auth.sh`, the `DICEE_*INFISICAL*` names in `scripts/lib/dicee-operator-metadata.sh` and its template, the `scripts/check-1password-setup.sh` checks and the wrapper and Codex-rule test cases are gone; the publication scan keeps its .infisical.json and private-hostname patterns as residual guards. Operator revokes the identities (status action 13). - History secret scan (pinned Gitleaks; the publication scan checks only candidate files) and a workflow policy check — both run in CI. - AKG graph drift gate: `pnpm akg:check` fails when discovery differs from the committed graph — `git diff --exit-code` on the graph after discovery. -- Remove or restore the dangling `web:analyze-logs` scripts, whose CLI entry file is missing — the script runs or is gone. -- Decide the unused `ENVIRONMENT` var (config audit B11; [open decision](cloudflare.md#open-decisions); default: delete it from both configs), then implement — `pnpm cf:audit --strict` loses the warning. +- Remove or restore the dangling `web:analyze-logs` scripts: the log-analyzer tool directory under packages/web is gitignored, so a clean clone has no CLI entry file — the script runs from a clean clone or is gone. +- Decide the unused `ENVIRONMENT` var (config audit B11; [open decision](cloudflare.md#open-decisions); default: delete it), then implement. Only `packages/cloudflare-do/wrangler.jsonc` still declares it, at the top level and in both named environments, and it is live on the deployed Worker, so removal ships with a `dicee` release — `pnpm cf:audit:strict` loses the warning. - Stop claiming untested Python 3.14: drop the 3.14 classifier and narrow `requires-python` to `>=3.13,<3.14` in `packages/analysis/pyproject.toml`, regenerating `uv.lock` in the same change — `uv lock --check --project packages/analysis` and `pnpm test:analysis`. ## 5. Toolchain Agent-safe. -- JS runtime pins and catalog patch/minor refresh; take wasm-pack from a verified source instead of the npm devDependency — `pnpm validate:ci`. +- Post-release maintenance PR, kept separate from this documentation reconciliation and never backported into the release commit: refresh the package manager and the Wrangler/miniflare pair from their current pins (`packageManager` pnpm 11.15.1 in `package.json` and `.mise.toml`; catalog `wrangler` 4.113.0 with `miniflare` 4.20260721.0 bumped in lockstep), respecting the miniflare hold in status decision 5, which `.github/dependabot.yml` also cites to suppress wrangler and miniflare security PRs — `pnpm validate:ci` green on its own PR, with the released commit unamended. +- JS runtime pins and the rest of the catalog patch/minor refresh; take wasm-pack from a verified source instead of the npm devDependency — `pnpm validate:ci`. - Node 26 after its 2026-10-28 LTS: widen `engines` and move the pins — `pnpm validate:ci` on Node 26. - mise-driven CI toolchain install; Rust, Python and wasm-pack refresh — CI green. - Biome config migration and a ratchet on the advisory warnings — the ratchet script passes. @@ -72,19 +76,19 @@ Agent-safe, in order. Gate for each: `pnpm validate` and `pnpm akg:check`. ## 7. Organization move with governance and IaC -Near-term direction (status decision 9). The GitHub repository transfer and local checkout move are complete. Remaining provider/infrastructure ownership changes and infrastructure adoption still wait for the Supabase key migration, HS256 removal and Infisical retirement. Section 1 keeps its operator stop points; this section does not advance them. +Near-term direction (status decision 9). The GitHub repository transfer and local checkout move are complete, and the application now runs on the committed two-Worker architecture, so discovery describes live resources rather than a planned shape. Remaining provider/infrastructure ownership changes and infrastructure adoption still wait for the Supabase key migration, HS256 removal and Infisical retirement. Section 1 keeps its operator stop points; this section does not advance them. -The [selected Cloudflare strategy](cloudflare.md#governance-strategy) preserves the current application shape and separates Jefahnierocks service governance from shared-account stewardship. The [organization alignment guide](development/organization-alignment.md) records the completed GitHub/local move and remains the proposal for the remaining provider and infrastructure boundaries. The repository transfer does not establish Cloudflare, Supabase, Google or infrastructure execution authority. +The [selected Cloudflare strategy](cloudflare.md#governance-strategy) preserves the running application shape and separates Jefahnierocks service governance from shared-account stewardship. The [organization alignment guide](development/organization-alignment.md) records the completed GitHub/local move and remains the proposal for the remaining provider and infrastructure boundaries. The repository transfer does not establish Cloudflare, Supabase, Google or infrastructure execution authority. -- Accept the Jefahnierocks intake: name the service owner, current shared-account steward, actual reviewers/operators, exact infrastructure repository/root and protected state boundary. Dicee-specific resources go in that owner's root even when account-scoped; global placement is for shared resources. Plan fresh inventory of namespace owners, Pages targets/domains, all ingress, secret names, token reach, zone/registrar ownership and GitHub controls — accepted responsibility/field map and private inventory, with unresolved items explicit. +- Accept the Jefahnierocks intake: name the service owner, current shared-account steward, actual reviewers/operators, exact infrastructure repository/root and protected state boundary. Dicee-specific resources go in that owner's root even when account-scoped; global placement is for shared resources. Plan fresh inventory of namespace owners, the canonical and any surviving legacy Worker scripts, custom domains, zone DNS and redirect rules, all other ingress, secret names, token reach, registrar ownership and GitHub controls — accepted responsibility/field map and private inventory, with unresolved items explicit. - **Completed 2026-09-14:** transferred the repository to `jefahnierocks/dicee`, moved the checkout to `~/Organizations/jefahnierocks/dicee`, preserved history, Actions secrets and the Production environment, updated repository identity metadata and the git remote, and left the old GitHub path unrecreated so its redirect remains available. GitHub governance is established separately by action 10. -- Establish separate inventory/plan, infrastructure-apply, application-release and local-operator credentials through accepted delivery paths; preserve the current 1Password wrapper and GitHub Environment wiring until replacements are accepted. Separate production and test consumers where supported; document effective account/zone permissions and remaining reach — capability readbacks, with no claim that a token label restricts access to one Worker. +- Establish separate inventory/plan, infrastructure-apply, application-release and local-operator credentials through accepted delivery paths; preserve the current 1Password wrapper and GitHub Environment wiring until replacements are accepted. The Pages Write reduction in section 1 is a narrowing of today's token, not this split. Separate production and test consumers where supported; document effective account/zone permissions and remaining reach — capability readbacks, with no claim that a token label restricts access to one Worker. - Adopt OpenTofu in the designated infrastructure root: pin tool/provider versions and commit the lockfile; establish ignore rules, approved encrypted state/plan storage, access controls and recovery before generating artifacts. Keep initial CI credential-free (fmt, validate, policy), and authenticated plans in protected environments. Discover/import existing resources without recreation; use the owning repository's accepted execution path — reviewed import/no-op plan and explicit authority for the first infrastructure write, followed by readback. -- Prove the Pages field boundary before managing its project with OpenTofu: retain application artifact, bindings, vars and compatibility in Wrangler; assign infrastructure identity/domain and any overlapping build fields explicitly. Verify import/no-op behavior, an authorized ordinary Wrangler release and the next infrastructure plan. Defer unmanaged fields or the resource when the pinned provider cannot preserve ownership — no unintended reset/recreation in that sequence. Keep Worker releases and v1/v2 Durable Object lifecycle in Wrangler; deliver runtime secret values outside infrastructure state. -- Retain the existing shared Cloudflare account under its current steward during alignment. Consider account relocation only for an accepted isolation/governance need, with a separate state-preservation, recovery, domain-cutover and verification plan. Resolve `dicee`/`dicee-production` namespace ownership before any Worker release; do not rename scripts/classes or assume a new account retains state — acceptance and current ownership/readback evidence before any relocation action. +- Prove the field boundary on the surfaces that actually overlap: the `dicee-web` custom domain serving `dicee.games`, the apex and `www` DNS records, and the zone redirect rules that canonicalize `www` and the retired legacy aliases onto the apex. Retain artifact, bindings, vars and compatibility in Wrangler; assign infrastructure identity and domain fields explicitly. Verify import/no-op behavior, an authorized ordinary Wrangler release and the next infrastructure plan. Defer unmanaged fields or the resource when the pinned provider cannot preserve ownership — no unintended reset, recreation or dropped redirect in that sequence. Keep Worker releases and v1/v2 Durable Object lifecycle in Wrangler; deliver runtime secret values outside infrastructure state. +- Retain the existing shared Cloudflare account under its current steward during alignment. Consider account relocation only for an accepted isolation/governance need, with a separate state-preservation, recovery, domain-cutover and verification plan. `dicee` is the confirmed owner of the live `GameRoom` and `GlobalLobby` namespaces; do not rename scripts/classes or assume a new account retains state — acceptance and current ownership/readback evidence before any relocation action. - Supabase project into an organization-owned Supabase org (same region; URL, JWKS and keys unchanged), with auth and other non-secret settings as code in plan-only mode and a `supabase/config.toml` parity check — sign-in and room-join readback. - Google OAuth client into an organization-governed Google Cloud project, in its own change window — sign-in readback. -- Decommission only classified obsolete scripts, credentials and memberships after checking namespace/data ownership, bindings, consumers and recovery. Keep unrelated shared-account resources and unresolved `dicee-production` intact; legacy keys stay deactivated — readback confirming the approved removals and retained required resources, not an empty shared-account inventory. +- Decommission only classified obsolete scripts, credentials and memberships after checking namespace/data ownership, bindings, consumers and recovery. The legacy Dicee Workers are section 1 work, not part of a blanket sweep; keep unrelated shared-account resources intact and legacy keys deactivated — readback confirming the approved removals and retained required resources, not an empty shared-account inventory. ## 8. Deferred with a trigger diff --git a/docs/status.md b/docs/status.md index 08550ba..e30ffcf 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,80 +1,73 @@ # Dicee status -**As of:** 2026-09-14T19:16:31Z +**As of:** 2026-09-15 -**Current phase:** 2026-09 operator safety rollout; GitHub governance complete, first production release next (no deployment yet) +**Current phase:** 2026-09 first production release complete on dicee-web; Pages deleted, legacy Worker cleanup and 20260913000002 remain Next work: [roadmap.md](roadmap.md). Cloudflare: [cloudflare.md](cloudflare.md). ## Current state -- `main` carries the reviewed baseline, the database privacy work, current dependency updates (Vitest 5, jsdom 30), the `dicee-web` Worker, the protocol handshake and the stats fix (action 7); none of it is deployed. -- Migration `20260913000001` is applied to the hosted project and recorded in migration history. Authenticated clients cannot update `profiles.role`; their editable profile fields remain granted. Migration `20260913000002` remains local-only. -- The profile-role audit (action 2) is complete: 7 profiles comprise 5 users and 2 super admins, with no moderators or admins. The operator confirmed both elevated assignments as intentional after private record review; no role changes were needed. Audit-log absence cannot establish that the old privilege was never exploited. -- The database backup is encrypted and verified on off-machine storage; Storage contained 0 objects. The plaintext exports were removed after verification. -- `workers.dev` and Preview URLs are disabled on `dicee` and `dicee-production`; `gamelobby-production` still exposes both. Namespace and binding discovery is complete; zone-level DNS and Worker-route reads remain unavailable to the scoped Cloudflare token and must be rechecked before deletion. -- The GitHub repository is `jefahnierocks/dicee`; `main` is protected by an active ruleset requiring `Full repository validation`, Production is reviewer-gated and main-only, and Dependabot alerts/security updates are enabled. The existing shared Cloudflare account and steward remain unchanged; provider/infrastructure ownership is separate future work. -- Credential containment (action 9) is complete: both exposed tokens (Cloudflare and Supabase) return HTTP 401 and their replacements authenticate (see readbacks). The Supabase CLI credential is a project-scoped token with only Database read-write access that expires 7 days after its 2026-09-14 creation; renew it before later operator steps need it. -- No application deployment has run during this operator rollout. CI deploys only on a manual `workflow_dispatch` from `main` with `deploy=true`. -- Legacy client layers are retired and the docs are consolidated into this file, the roadmap, `docs/cloudflare.md`, `docs/architecture/` and `docs/development/`. Git history is the archive. +- The 2026-09 release deployed the reviewed baseline commit from an isolated clean checkout, with operator commands rather than a CI dispatch; `main` has not advanced since. +- `dicee-web` is the canonical public Worker: custom domain `dicee.games`, Workers Static Assets with `ASSETS`, `workers.dev` and Preview URLs disabled, and one service binding `GAME_WORKER` to `dicee`. The game Worker keeps no public ingress. Keep both Workers; do not collapse them. +- The Pages project `dicee` is deleted, after all four of its public hostnames moved off it. Web releases now target `dicee-web` directly, and reattaching a domain to Pages is no longer a rollback path: roll forward. +- `20260913000001`, `20260914000001` and `20260914000002` are applied and recorded on the hosted project. `20260913000002` stays local-only until the profile visibility release (action 4). +- `player_stats` is a rebuildable projection that also stores AI seats; the one-time rebuild produced 2 rows, matching the single historical completed game with two scored human participants. The project has 0 Supabase Edge Functions, so there was no deployed `aggregate-game-stats` function to delete. +- Game and player reads run through the SECURITY DEFINER helper `is_game_participant` instead of recursive RLS, and an authenticated-role smoke returned rows without recursion. +- `www.dicee.games` redirects inside the `dicee.games` zone; `dicee.jefahnierocks.com` and `gamelobby.jefahnierocks.com` redirect from the separate `jefahnierocks.com` zone, so the public surface depends on a zone this repository does not otherwise describe. All three are zone-level 301s that preserve path and query, none is a Worker custom domain, and the old redirect that prepended a game path is retired. +- `dicee-production`, `gamelobby` and `gamelobby-production` are the remaining legacy Workers. None has a custom domain or a Worker service-binding consumer; `gamelobby-production` still exposes `workers.dev` and Preview URLs; zone-level Worker-route reads still return HTTP 403 to the scoped Cloudflare token, which stays scoped. +- The repository is `jefahnierocks/dicee` with `main` protected and Production reviewer-gated and main-only. The release deliberately stayed on the legacy Supabase anon key; key modernization is future work (actions 12 and 15). ## Decisions -1. **Durable Objects.** Keep the legacy `migrations` v1 (`GameRoom`) and v2 (`GlobalLobby`) with `new_sqlite_classes`. Adopt declarative `exports` only for a concrete need, as a standalone operator deploy. `dicee` is the one game backend. Live readback confirms Pages production binds `GAME_WORKER` to `dicee`, whose SQLite `GameRoom` and `GlobalLobby` namespaces are at migration tag v2, matching source; no no-op lifecycle deploy or cutover is required. Legacy namespace pairs on other scripts are cleanup candidates, not the production owner. +1. **Durable Objects.** Keep the legacy `migrations` v1 (`GameRoom`) and v2 (`GlobalLobby`) with `new_sqlite_classes`. Adopt declarative `exports` only for a concrete need, as a standalone operator deploy. `dicee` is the one game backend, reached only through the `GAME_WORKER` binding on `dicee-web`; its SQLite `GameRoom` and `GlobalLobby` namespaces are at migration tag v2 and the release changed no tag. Legacy namespace pairs on other scripts are cleanup candidates, not the production owner. 2. **MCP.** `akg` (stdio) and unauthenticated `cloudflare-docs` are enabled. `cloudflare-api` and read-only Supabase are opt-in OAuth servers. No bearer-token wrappers or credential bridges. -3. **Secrets.** Infisical is retired. CI reads GitHub Environment secrets; local operator commands resolve 1Password secrets per command. -4. **Database.** `20260913000001` was applied alone. `20260913000002` applies only after a production deploy that includes the profile visibility opt-in control (actions 3-4). -5. **Wrangler.** Stay on the miniflare 4 line: wrangler 4.113.0 with `compatibility_date` 2026-07-21. Dependabot cites this decision number. +3. **Secrets.** Infisical is retired as a provider choice: no new usage, and the remaining scripts and metadata names come out through [roadmap section 4](roadmap.md#4-repository-hygiene) with the identities revoked in action 13. CI reads GitHub Environment secrets; local operator commands resolve 1Password secrets per command. +4. **Database.** `20260913000001` was applied alone, then `20260914000001` and `20260914000002` together with the release. `20260913000002` applies only after a production deploy that includes the profile visibility opt-in control (action 4). +5. **Wrangler.** Stay on the miniflare 4 line: the repository pins wrangler 4.113.0, and both Workers now run `compatibility_date` 2026-07-21. Dependabot cites this decision number. 6. **Homes.** This file is the status of record; [roadmap.md](roadmap.md) is the only sequence of work; git history is the archive. 7. **Data platform.** Stay on Supabase (Auth and Postgres), hardened and minimized. -8. **Cloudflare platform.** A `dicee-web` Worker on Workers Static Assets plus the `dicee` game Worker with SQLite Durable Objects, over one service binding. Pages still serves `dicee.games` until the first release moves it, then the Pages project is deleted. No hosted preview backed by production. No D1, R2 or combined web-and-game Worker without a concrete need. -9. **Jefahnierocks Cloudflare strategy.** Jefahnierocks now owns the GitHub repository; the existing shared Cloudflare account and steward remain retained during infrastructure alignment. OpenTofu will manage explicitly assigned infrastructure fields in an accepted Jefahnierocks root; Wrangler keeps application releases, bindings and Durable Object lifecycle. Separate inventory/plan, infrastructure-apply, application-release and local-operator credentials, with effective reach verified. Preserve names/state; Pages field overlap must pass import/release/plan checks. Intake, exact infrastructure placement and any later account relocation still need acceptance/evidence. Ownership changes follow the credential prerequisites in [roadmap section 7](roadmap.md#7-organization-move-with-governance-and-iac); [Cloudflare strategy](cloudflare.md#governance-strategy) owns the design details. +8. **Cloudflare platform.** A `dicee-web` Worker on Workers Static Assets plus the `dicee` game Worker with SQLite Durable Objects, over one service binding. `dicee-web` serves `dicee.games` and the Pages project is deleted, so recovery is roll-forward rather than reattaching a domain. No hosted preview backed by production. No D1, R2 or combined web-and-game Worker without a concrete need. +9. **Jefahnierocks Cloudflare strategy.** Jefahnierocks owns the GitHub repository; the existing shared Cloudflare account and steward remain retained during infrastructure alignment. OpenTofu will manage explicitly assigned infrastructure fields — zone, DNS, redirect rules and the web Worker's custom domain — in an accepted Jefahnierocks root; Wrangler keeps application releases, bindings and Durable Object lifecycle. Separate inventory/plan, infrastructure-apply, application-release and local-operator credentials, with effective reach verified. Preserve names and state; any overlapping Worker or domain field must pass import/release/plan checks before OpenTofu manages it. Intake, exact infrastructure placement and any later account relocation still need acceptance and evidence. Ownership changes follow the credential prerequisites in [roadmap section 7](roadmap.md#7-organization-move-with-governance-and-iac); [Cloudflare strategy](cloudflare.md#governance-strategy) owns the design details. 10. **Agent surface.** `AGENTS.md` plus package `AGENTS.md` files; portable skills in .agents/skills (symlinked for Claude); Codex policy in `.codex/rules`. Windsurf and Cascade, CODEX.md, GEMINI.md, Cursor rule files and the Copilot MCP files are retired. ## Open operator actions -These are stable action identifiers, not execution order. [Roadmap section 1](roadmap.md#1-safety-now) owns the sequence; action 10 (GitHub governance) is the next unfinished operator step. Mark an action done only with a first-hand readback in [Latest live readbacks](#latest-live-readbacks). Existing authorization remains scoped to the requested operation and its stop points. +These are stable action identifiers, not execution order, and other documents cite them by number. [Roadmap section 1](roadmap.md#1-safety-now) owns the sequence; action 8 (legacy Worker deletion) is the next operator step. Mark an action done only with a first-hand readback recorded in [Latest live readbacks](#latest-live-readbacks), or summarized in the action once its row is superseded. Existing authorization remains scoped to the requested operation and its stop points. 1. [x] **Backup.** The operator confirmed the Free plan. Five SQL dumps and the Storage inventory were verified in an AES-256 image on off-machine storage; Storage contained 0 objects. Plaintext exports were removed only after the copied image passed verification. -2. [x] **Profile-role audit.** Migration `20260913000001` is applied and recorded; do not reapply it. Role counts and both elevated records were retrieved privately. The operator confirmed both super-admin assignments as intentional; 0 unresolved accounts and 0 corrections. -3. [ ] **First production release.** After actions 5 and 10, release the exact validated commit from a clean checkout at a quiet time: read back completed game and `player_stats` counts (the stats rebuild resets stats no completed game backs), apply `20260914000001` and `20260914000002` (game read policies) alone, deploy `dicee-web`, move `dicee.games` off Pages, deploy `dicee` (its protocol gate closes the old Pages client's sockets), run the [post-deploy smoke checks](cloudflare.md#deploy-path), then delete the deployed Edge Function and rebuild all stats once. +2. [x] **Profile-role audit.** Migration `20260913000001` is applied and recorded; do not reapply it. Role counts were 5 users and 2 super admins with no moderators or admins; the operator confirmed both elevated assignments as intentional after private record review, with 0 corrections. Audit-log absence cannot establish that the old privilege was never exploited. +3. [x] **First production release.** Done 2026-09-15 from an isolated clean checkout of the released commit, with operator commands rather than a CI dispatch: `20260914000001` and `20260914000002` applied and recorded, `player_stats` rebuilt once, `dicee-web` deployed and given `dicee.games`, and `dicee` redeployed from the same commit with its Durable Object tag unchanged. The public smoke covered routing, security headers and the lobby REST path through the service binding; the browser-side items in the [post-deploy smoke checks](cloudflare.md#deploy-path) — sign-in, WebSocket upgrades, a persisted game, transcription and non-admin refusal — have no recorded readback and are still worth an operator pass. 4. [ ] **Apply `20260913000002`** only after the missing profile visibility opt-in control is implemented, tested, merged and verified in production. Current source omits the four bug-report fields this migration drops, but deployed compatibility is unverified. Take a fresh complete encrypted backup, recheck the production link and migration history, and apply only this migration. It resets all profiles to private; verify the schema and two-account privacy behavior before inviting opt-ins. Do not improvise a reverse migration; fix forward with the compatible opt-in build. -5. [x] **Worker namespace and binding check.** Live readback found SQLite `GameRoom` and `GlobalLobby` namespace pairs on `dicee`, `dicee-production` and `gamelobby-production`; the namespace-owning scripts are at migration tag v2. Pages production binds `GAME_WORKER` to `dicee` and preview has no service binding. Source `dicee` declares the same v1/v2 lifecycle, so no no-op lifecycle deploy or cutover is required. -6. [x] **Worker subdomain URLs.** `workers.dev` and Preview URLs are disabled on `dicee` and `dicee-production`, verified by API. Namespace ownership and other ingress remain for the later reviews in actions 5 and 8. -7. [ ] **Stats aggregation.** Fixed in source: `20260914000001` makes `player_stats` a rebuildable projection and stores AI seats, and the Worker now sends JSON arrays (its old array literals were rejected, so the deployed Worker has likely never persisted a game). Complete with action 3: delete the deployed `aggregate-game-stats` Edge Function, then rebuild all stats once. -8. [ ] **Delete obsolete surfaces.** `dicee-production`, `gamelobby` and `gamelobby-production` are legacy cleanup candidates; no deployed Worker service binding consumes them. `gamelobby-production` still exposes `workers.dev` and Preview URLs, and zone-level Worker routes/DNS remain unverified because the scoped token receives HTTP 403. Recheck those surfaces immediately before deletion. Delete Pages `dicee` only after the `dicee-web` cutover; live Durable Object state on legacy scripts is not preserved. +5. [x] **Worker namespace and binding check.** SQLite `GameRoom` and `GlobalLobby` namespace pairs live on `dicee`, `dicee-production` and `gamelobby-production`, all at migration tag v2. `dicee-web` binds `GAME_WORKER` to `dicee`, and the release changed no lifecycle tag. The legacy pairs are cleanup candidates (action 8), not the production owner. +6. [x] **Worker subdomain URLs.** `workers.dev` and Preview URLs are disabled on `dicee`, `dicee-web`, `dicee-production` and `gamelobby`, verified by API. `gamelobby-production` still has both enabled and needs explicit treatment in action 8. +7. [x] **Stats aggregation.** `20260914000001` makes `player_stats` a rebuildable projection and stores AI seats, and the Worker now sends JSON arrays in place of the array literals Postgres rejected. The one-time rebuild ran with action 3. The planned step to delete a deployed `aggregate-game-stats` Edge Function was not applicable rather than done: the project's Edge Function inventory is 0. +8. [ ] **Delete obsolete surfaces.** Pages `dicee` is deleted. `dicee-production`, `gamelobby` and `gamelobby-production` remain, each with 0 custom domains and 0 Worker service-binding consumers, and zone-level Worker routes stay unreadable (HTTP 403) under the scoped token; do not broaden it for this cleanup. Read each script's Domains & Routes in the dashboard immediately before deleting it. Delete `gamelobby` first — no Durable Object namespaces, only the `SUPABASE_URL` and `SUPABASE_ANON_KEY` secret names — with a normal non-force delete, then reverify the public app. Treat `dicee-production` and `gamelobby-production` separately: both own SQLite namespaces whose legacy state a force delete destroys, which needs an explicit decision, and `gamelobby-production` also still has direct `workers.dev` and Preview URL ingress. 9. [x] **Credential containment.** Both exposed tokens are replaced, revoked and verified dead by direct HTTP 401 readbacks. Cloudflare: the replacement is canonical in 1Password and GitHub Production, and the repository duplicate is removed. Supabase: a project-scoped token with only Database read-write access is the sole CLI credential; temporary copies, the environment override, the fallback token file and plaintext copies are absent. 10. [x] **GitHub governance.** Active `main` ruleset with no bypass entries requires the exact check **Full repository validation** with strict status checks and blocks deletion/non-fast-forward updates. `Production` requires one reviewer, permits self-review, and accepts deployments only from `main`. Dependabot alerts and security updates are enabled. 11. [ ] **Supabase default grants change on 2026-10-30** for newly created tables; existing tables keep their grants. Apply the explicit-grants migration from the roadmap first ([change notice](https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically)). 12. [ ] Migrate off the legacy `anon` and `service_role` API keys before the announced end-of-2026 deprecation. Verify the final schedule before cutover ([migration guide](https://supabase.com/docs/guides/getting-started/migrating-to-new-api-keys)). 13. [ ] Revoke unused Infisical machine identities and any leftover Vercel or PartyKit credentials. -14. [ ] Update the meta-inventory registry so `status_of_record` is `docs/status.md`. +14. [ ] Update the external meta-inventory registry so `status_of_record` is `docs/status.md` and its recorded phase mirrors this file. `project.yaml` already carries both; the registry is outside this repository and cannot be verified from here. +15. [ ] **Re-set the GitHub `PUBLIC_SUPABASE_ANON_KEY` secret** through the approved secret-delivery path, so a CI release uses the same current legacy anon key the manual release verified functionally. GitHub never returns a secret value, so only the name is known to be present. Do not silently switch to the newer publishable-key format; that belongs to action 12. +16. [ ] **Cloudflare deploy-token scope.** With the Pages project deleted, Pages Write has no consumer. Review the deploy and local-operator token's effective permissions and drop Pages Write if nothing else needs it, keeping Workers Scripts Write. This is credential governance, independent of action 8. +17. [ ] **Supabase CLI credential.** The project-scoped Database read-write token expires seven days after its 2026-09-14 creation. Reissue it before action 4 or any [roadmap section 2](roadmap.md#2-supabase-obligations) migration needs it; record the expiry date only, never the value. ## Latest live readbacks -One row per subject, replaced when superseded. Counts and names only; never secret values or account identifiers. - -### Database hardening (actions 1-2) - -Recorded results; do not rerun the database checks. The operator confirmed that the verified NAS copy satisfies the off-site requirement. - -| Subject | UTC | Request | Result | -|---|---|---|---| -| Supabase plan tier | 2026-09-13 | Operator plan-tier confirmation | Free | -| Backup moved off-site | 2026-09-13T20:32:35Z | Mounted-image and checksum verification | Yes; verified encrypted NAS copy | -| Storage object count | 2026-09-13T20:32:35Z | Storage inventory | 0 | -| 000001 applied | 2026-09-13T20:46:01Z | CLI: migration history after successful SQL and repair | Yes; `20260913000001` applied | -| 000002 applied | 2026-09-13T20:46:01Z | CLI: migration history | No; `20260913000002` not applied | -| Privilege check results | 2026-09-13T21:03:57Z | CLI: privilege and migration-object queries | Table / role / display_name UPDATE: anon false / false / false; authenticated false / false / true; service_role true / true / true, unchanged; 2 required noninternal triggers; 1 owner insert policy | -| Role counts | 2026-09-13T21:39:34Z | CLI: grouped profile-role counts | user 5; moderator 0; admin 0; super_admin 2 | -| Unexpected elevated roles revoked | 2026-09-13T21:41:56Z | Operator review of both captured elevated records | 0 | - -### Other readbacks +One row per subject, replaced when superseded. Counts, names and HTTP status only; never secret values, account or zone identifiers, namespace ids or project refs. The 2026-09-13 database-hardening evidence for actions 1-2 is summarized in those actions and kept in git history. | Subject | UTC | Request | Result | |---|---|---|---| -| Worker inventory | 2026-09-14T18:58:21Z | API: scripts, Durable Object namespaces, Worker metadata and Pages bindings | 11 scripts; Pages production binds `GAME_WORKER` to `dicee`; `dicee`, `dicee-production` and `gamelobby-production` hold SQLite `GameRoom`/`GlobalLobby` namespace pairs at v2; source `dicee` matches v1/v2, so no lifecycle deploy or cutover is required | -| Worker subdomain URLs | 2026-09-14T18:58:21Z | API: subdomains, custom domains, cron and service-binding consumers | `dicee`, `dicee-production` and `gamelobby` have direct Worker URLs disabled; `gamelobby-production` has workers.dev and Preview URLs enabled; no custom domains, cron or Worker service-binding consumers were found; zone DNS/routes remain unresolved under the scoped token | -| GitHub governance | 2026-09-14T19:16:31Z | REST: repository/effective rules, Production environment and Dependabot controls | Active `main` ruleset: no bypass entries; deletion and non-fast-forward blocked; strict `Full repository validation` required. Production: 1 required reviewer, self-review permitted, custom deployment branch policy `main` only. Dependabot alerts and security updates enabled | -| Credential containment | 2026-09-14T06:25:02Z | Cloudflare (04:41:28Z): private token verification, wrapper auth, GitHub secret-name readback. Supabase: dashboard last-used match, Management API call with the preserved old token, CLI `SELECT 1` with the replacement, local-copy inventory | Cloudflare: replacement canonical in 1Password; Production `CLOUDFLARE_API_TOKEN` present; repository duplicate absent; old token HTTP 401. Supabase: old token HTTP 401, other account tokens unchanged; project-scoped Database read-write replacement authenticates; environment override, fallback file, temporary Keychain copies and plaintext copies absent. No deployment or database mutation | -| Cloudflare Pages and build triggers | 2026-09-13T04:35Z | API: Pages project `dicee`, Workers Builds triggers | Pages has no Git source, production branch `main`; trigger reads returned 403, so triggers are unverified | -| GitHub Apps | 2026-09-13T04:49Z | Repository installed GitHub Apps page | No Cloudflare Workers and Pages app; with no Pages Git source, the native Git build integration is not in use | +| Hosted migration state | 2026-09-15 | CLI: migration history after the transactional apply and repair | `20260913000001`, `20260914000001` and `20260914000002` applied and recorded; `20260913000002` not applied | +| Stats projection | 2026-09-15 | SQL: post-migration schema, then the one-time rebuild | `game_players.user_id` nullable with `is_ai` and `ai_profile` present, seat-number primary key, unique game/user index and the AI/human seat-identity check; `rebuild_player_stats` and `refresh_player_stats_for_game` present, old `update_category_stats` removed; `player_stats` 2 rows, `games_played` 2, `games_completed` 2 | +| Game read policies | 2026-09-15 | SQL: function definition and grants, then an authenticated-role smoke | `is_game_participant` is SECURITY DEFINER, stable, has an empty search path and grants EXECUTE to anon, authenticated and service_role; the `games` and `game_players` SELECT policies no longer recurse; smoke returned 2 game players, 1 game, 3 domain events, 0 rooms | +| Database baseline | 2026-09-15 | SQL: game counts; CLI: Edge Function inventory | 1 completed game; 2 active games, both stale historical rows, 0 started in the last 24 hours; 0 Edge Functions | +| Web Worker release | 2026-09-15 | API: `dicee-web` deployment, bindings and domains; HTTPS GET of the served build version | compatibility date 2026-07-21; Workers Static Assets with `ASSETS`; `GAME_WORKER` to `dicee`; `workers.dev` and Preview URLs disabled; custom domain `dicee.games`; the served build matched the local release artifact after cutover and again after Pages removal | +| Game Worker release | 2026-09-15 | API: `dicee` deployment, bindings and secret names | compatibility date moved 2024-12-01 to 2026-07-21; Durable Object migration tag still v2; `GameRoom`, `GlobalLobby`, Workers AI and the production var binding present; secret names unchanged; `workers.dev`, Preview URLs and custom domains all absent | +| Public application smoke | 2026-09-15 | HTTPS GET on the canonical origin | `/` 200; `/games/dicee` 200; `/lobby` an intentional 301 to `/`, which returns 200; HSTS, CSP, Permissions-Policy, Referrer-Policy and X-Content-Type-Options present on the root | +| Service binding through the public origin | 2026-09-15 | HTTPS GET `/api/lobby/rooms` and `/api/lobby/online` | 200 JSON with a top-level `rooms` array, 6 entries during the smoke; 200 JSON with top-level `connections` and `count`; direct evidence that `dicee-web` reaches `GlobalLobby` through `GAME_WORKER` | +| Canonical redirects | 2026-09-15 | HTTP and HTTPS GET with paths and query strings | `www.dicee.games`, `dicee.jefahnierocks.com` and `gamelobby.jefahnierocks.com` each return 301 to the apex with path and query preserved, by zone Single Redirect rules over proxied placeholder A records; none is a Worker custom domain | +| Pages project | 2026-09-15 | API: GET the Pages project after deletion | HTTP 404 and the project absent; its custom-domain count reached 0 before deletion, and the app, redirects, served build and database baseline were reverified afterwards | +| Legacy Worker inventory | 2026-09-15 | API: scripts, domains, secret names, service-binding consumers and Durable Object namespaces | `dicee-production`, `gamelobby` and `gamelobby-production` remain, each with 0 custom domains and 0 service-binding consumers; `gamelobby-production` has `workers.dev` and Preview URLs enabled; `dicee-production` and `gamelobby-production` hold SQLite `GameRoom`/`GlobalLobby` namespaces, `gamelobby` holds none; zone-level Worker-route reads returned HTTP 403 | +| GitHub governance and Production variable | 2026-09-15; rules 2026-09-14T19:16:31Z | REST: repository rules, Production environment, Dependabot controls and environment variables | Active `main` ruleset: no bypass entries, deletion and non-fast-forward blocked, strict **Full repository validation** required. Production: 1 required reviewer, self-review permitted, `main` only. Dependabot alerts and security updates enabled. `PUBLIC_SUPABASE_URL` matches the linked Supabase project; `PUBLIC_SUPABASE_ANON_KEY` is present by name only | +| Credential containment | 2026-09-14T06:25:02Z | Cloudflare and Supabase: calls with the preserved old tokens and with their replacements, plus a local-copy inventory | Both old tokens HTTP 401. Cloudflare: replacement canonical in 1Password, Production `CLOUDFLARE_API_TOKEN` present, repository duplicate absent. Supabase: the project-scoped Database read-write replacement authenticates; environment override, fallback file and plaintext copies absent | diff --git a/packages/web/src/routes/ws/lobby/+server.ts b/packages/web/src/routes/ws/lobby/+server.ts index 91d2c87..6077cff 100644 --- a/packages/web/src/routes/ws/lobby/+server.ts +++ b/packages/web/src/routes/ws/lobby/+server.ts @@ -1,7 +1,7 @@ /** * WebSocket Proxy: /ws/lobby → GlobalLobby Durable Object * - * Proxies WebSocket connections from the Pages frontend to the + * Proxies WebSocket connections from the dicee-web Worker to the * GlobalLobby DO via Service Binding. This enables same-origin * WebSocket connections without CORS. * diff --git a/packages/web/src/routes/ws/room/[code]/+server.ts b/packages/web/src/routes/ws/room/[code]/+server.ts index dabf0e9..938369e 100644 --- a/packages/web/src/routes/ws/room/[code]/+server.ts +++ b/packages/web/src/routes/ws/room/[code]/+server.ts @@ -1,7 +1,7 @@ /** * WebSocket Proxy: /ws/room/[code] → GameRoom Durable Object * - * Proxies WebSocket connections from the Pages frontend to the + * Proxies WebSocket connections from the dicee-web Worker to the * GameRoom DO via Service Binding. This enables same-origin * WebSocket connections without CORS. * diff --git a/project.yaml b/project.yaml index 512b2fb..1d96179 100644 --- a/project.yaml +++ b/project.yaml @@ -10,8 +10,8 @@ role: canonical peers: [] status: posture: deployed-system - local_phase: "2026-09 operator safety rollout; GitHub governance complete, first production release next (no deployment yet)" - as_of: "2026-09-14T19:16:31Z" + local_phase: "2026-09 first production release complete on dicee-web; Pages deleted, legacy Worker cleanup and 20260913000002 remain" + as_of: "2026-09-15" authority: status_of_record: docs/status.md presentation: From 98faffe54a1f2fd3d1d12e8af4ff1f5e23a2fac7 Mon Sep 17 00:00:00 2001 From: verlyn13 Date: Tue, 15 Sep 2026 19:18:45 -0800 Subject: [PATCH 2/4] docs(agents): record the Claude surface ratchet and the local-settings hazard Three rules existed only as gate code with no stated reason, so a future reader could not tell a deliberate constraint from an accident. - Say why lint:docs refuses a hooks key and tracked command/hook files: it is an anti-regrowth ratchet from the 2026-09-13 cleanup, not a claim that the features are useless, and Claude agent and rule directories are deliberately outside it. - Warn against enableAllProjectMcpServers. It enables every .mcp.json entry including the opt-in OAuth servers, and the gate cannot catch it because it reads only the shared settings file. - Note that the skill front-matter fields and the 60-line cap are enforced. --- docs/development/agent-clients.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/development/agent-clients.md b/docs/development/agent-clients.md index 563e49b..bf0df56 100644 --- a/docs/development/agent-clients.md +++ b/docs/development/agent-clients.md @@ -19,6 +19,8 @@ The following are retired and must not be reintroduced: Windsurf and Cascade files, root client files other than `AGENTS.md` and `CLAUDE.md`, Cursor project rules, the Copilot MCP template, and path-scoped Copilot instruction files. +`pnpm lint:docs` also refuses a `hooks` key in `.claude/settings.json`, and refuses tracked files in the Claude commands and hooks directories or a hooks directory under scripts. That is a ratchet, not a judgement on the features: those paths had accumulated roughly 3,500 lines of stale generated guidance and session scripts, which one 2026-09-13 change removed in favour of the two portable skills. Reversing it is an owner decision, and the argument has to be a concrete need rather than "the client supports it". Claude agent and rule directories are deliberately not part of the ratchet. + ## MCP | Server | Transport | Auth | Default | @@ -30,7 +32,7 @@ The following are retired and must not be reintroduced: Windsurf and Cascade fil - **Which tool for which task.** Use `akg` for imports and invariants and `cloudflare-docs` for Cloudflare product docs. `cloudflare-api` is for live account reads and read-only `supabase` for schema inspection. Migrations go through `supabase/migrations/` and the Supabase CLI under explicit authority, never through MCP. - **An MCP session is not authority.** Deploys, remote database writes, migrations, and binding or secret changes need explicit user authority, whatever the OAuth grant allows. -- **Claude Code.** `.claude/settings.json` `enabledMcpjsonServers` approves `akg` and `cloudflare-docs`. Accept workspace trust. To keep an opt-in server off, list it under `disabledMcpjsonServers` in your ignored local settings file (settings.local.json next to the shared settings). Sign in with `/mcp` or `claude mcp login cloudflare-api` / `claude mcp login supabase`, granting the narrowest consent. Non-interactive runs load project servers without a prompt, and an OAuth server with no stored session exposes no tools. +- **Claude Code.** `.claude/settings.json` `enabledMcpjsonServers` approves `akg` and `cloudflare-docs`. Accept workspace trust. To keep an opt-in server off, list it under `disabledMcpjsonServers` in your ignored local settings file (settings.local.json next to the shared settings). Never set `enableAllProjectMcpServers`: it enables every entry in `.mcp.json`, including the opt-in OAuth servers, and the docs gate cannot see it because it reads only the shared settings file. Sign in with `/mcp` or `claude mcp login cloudflare-api` / `claude mcp login supabase`, granting the narrowest consent. Non-interactive runs load project servers without a prompt, and an OAuth server with no stored session exposes no tools. - **Supabase project scope.** The URL reads `DICEE_SUPABASE_PROJECT_REF`. Set it as a non-secret export in the ignored `.envrc.local.nonsecret`, which `.envrc` sources. When the variable is unset, Claude Code keeps the literal placeholder and the server rejects it, so the entry fails closed. No project ref is ever committed. - **Cursor.** `.cursor/mcp.json` lists only `akg` (`"type": "stdio"`) and `cloudflare-docs` (`url`). An authorized opt-in adds `cloudflare-api` or `supabase` to that project-native file with client OAuth; never add Dicee servers to user-global configuration. Use Cursor's `${env:DICEE_SUPABASE_PROJECT_REF}` interpolation for the Supabase project parameter (Claude uses `${DICEE_SUPABASE_PROJECT_REF}`). Keep provider identifiers out of committed configuration and leave `supabase` off until its non-secret project variable is set. - **Codex.** `.codex/config.toml` declares `akg` and `cloudflare-docs` only. @@ -38,7 +40,7 @@ The following are retired and must not be reintroduced: Windsurf and Cascade fil ## Skills -- Portable skills live in `.agents/skills//SKILL.md` (`dicee-verify`, `akg-boundaries`). Each file needs `name`, matching its directory, and `description`. +- Portable skills live in `.agents/skills//SKILL.md` (`dicee-verify`, `akg-boundaries`). Each file needs `name`, matching its directory, and `description`. `pnpm lint:docs` enforces both fields and a 60-line cap per skill. - Claude Code does not read `.agents/skills/`, so `.claude/skills/` is a symlink to `../../.agents/skills/`. Edit the target, never the link. - Codex reads `.agents/skills/` directly. - Cursor reads both directories, so it lists each skill twice. This is cosmetic. From 9d6343611904de7c1024c759ee6aee91773b58b6 Mon Sep 17 00:00:00 2001 From: verlyn13 Date: Tue, 15 Sep 2026 20:18:28 -0800 Subject: [PATCH 3/4] chore(agents): modernize the agent-client surface for the 2026-09 toolchain One repository contract, thin client adapters, no repo-wide model pinning, and a check that catches drift in the places the docs gate structurally cannot see. Boundaries encoded rather than described: - .claude/settings.json denies mcp__supabase__execute_sql and mcp__supabase__apply_migration. Deny outranks allow from any settings file, so the prohibition now survives a local override. - .codex/rules/dicee.rules no longer auto-allows linked-project Supabase writes. Read-only inspection stays allow; db push/pull/dump/reset, migration repair/up/apply, link, login, secrets, functions deploy/delete, gen types, pnpm db:types and op read/item are prompt. Destructive Git stays forbidden. op item was added because it reaches the same credential as op read. Drift detection: - scripts/agent-doctor.mjs, dependency-free and in the house style. Committed checks run in CI; local checks run only when the file or client exists, so an absent ignored file can never fail CI. It is what would have caught the enableAllProjectMcpServers hole and the AKG launcher drift below. Completions rather than transitional states: - Infisical is out: launcher, metadata names, credential checks, wrapper tests and command-policy rules deleted, tracked references down from 15 files to 8, each one a deliberate guard or a retirement note. A retired-token entry in the docs gate keeps the remainder visible and shrinking. - The dead Cloudflare Pages permission and policy entries are gone. - check-1password-setup.sh no longer fails closed on display-only identifiers. Consistency: - Every client launches the AKG MCP server through mise, so it runs on the pinned Bun rather than whatever is first on that client's PATH. - agent-clients.md records supported minimums instead of pinning patch releases, corrects the Codex rules description, and adds model guidance as guidance. pnpm lint green. agent-doctor: 0 errors. 40 doctor fixtures pass. --- .claude/settings.json | 7 +- .codex/config.toml | 4 +- .codex/rules/dicee.rules | 395 +++-- .cursor/mcp.json | 5 +- .mcp.json | 5 +- AGENTS.md | 2 +- docs/development/agent-clients.md | 55 +- docs/development/organization-alignment.md | 2 +- docs/roadmap.md | 3 +- docs/status.md | 2 +- package.json | 4 +- scripts/agent-doctor.mjs | 1568 +++++++++++++++++ scripts/check-1password-setup.sh | 19 +- scripts/check-docs.config.json | 13 + scripts/lib/dicee-operator-metadata.sh | 39 +- scripts/public-safety-scan.sh | 3 + .../dicee-operator-metadata.example.sh | 28 +- scripts/tests/agent-doctor.test.sh | 523 ++++++ scripts/tests/codex-rules.test.sh | 78 +- scripts/tests/credential-wrappers.test.sh | 99 +- scripts/with-dicee-infisical-auth.sh | 74 - 21 files changed, 2591 insertions(+), 337 deletions(-) create mode 100644 scripts/agent-doctor.mjs create mode 100644 scripts/tests/agent-doctor.test.sh delete mode 100755 scripts/with-dicee-infisical-auth.sh diff --git a/.claude/settings.json b/.claude/settings.json index 26097bf..ec0ac3f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -43,26 +43,21 @@ "Bash(pnpm deploy*)", "Bash(pnpm do:deploy*)", "Bash(pnpm do:tail*)", - "Bash(pnpm pages:deploy*)", "Bash(pnpm db:types*)", "Bash(pnpm --filter * deploy*)", - "Bash(pnpm --filter * pages:deploy*)", "Bash(pnpm --filter * tail*)", "Bash(pnpm --filter * exec wrangler *)", "Bash(pnpm --filter * exec supabase *)", "Bash(pnpm --dir * deploy*)", - "Bash(pnpm --dir * pages:deploy*)", "Bash(pnpm --dir * tail*)", "Bash(pnpm --dir * exec wrangler *)", "Bash(pnpm exec wrangler deploy*)", "Bash(pnpm exec wrangler versions deploy*)", - "Bash(pnpm exec wrangler pages deploy*)", "Bash(pnpm exec wrangler secret*)", "Bash(pnpm exec wrangler tail*)", "Bash(wrangler deploy*)", "Bash(wrangler versions deploy*)", "Bash(wrangler rollback*)", - "Bash(wrangler pages deploy*)", "Bash(wrangler secret*)", "Bash(wrangler tail*)", "Bash(wrangler login*)", @@ -85,6 +80,8 @@ "Bash(git push*)" ], "deny": [ + "mcp__supabase__execute_sql", + "mcp__supabase__apply_migration", "Bash(git reset --hard*)", "Bash(git clean *)", "Bash(git push --force*)", diff --git a/.codex/config.toml b/.codex/config.toml index fb1e29e..2c15a08 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -7,8 +7,8 @@ max_concurrent_threads_per_session = 4 max_depth = 1 [mcp_servers.akg] -command = "bun" -args = ["run", "packages/web/src/tools/akg/mcp/server.ts"] +command = "mise" +args = ["exec", "--", "bun", "run", "packages/web/src/tools/akg/mcp/server.ts"] env = { AKG_GRAPH_PATH = "docs/architecture/akg/graph/current.json", AKG_DIAGRAMS_PATH = "docs/architecture/akg/diagrams", AKG_PROJECT_ROOT = "." } [mcp_servers.cloudflare-docs] diff --git a/.codex/rules/dicee.rules b/.codex/rules/dicee.rules index 3157162..dc119f5 100644 --- a/.codex/rules/dicee.rules +++ b/.codex/rules/dicee.rules @@ -1,18 +1,30 @@ # Dicee Codex command policy. # -# Dicee is a trusted experimental agentic project. -# The prefixes below allow Supabase operation, type generation, and direct -# 1Password secret reads at the execution-policy layer. Task authority and -# runbook stop conditions still apply; live type generation is not a local gate. +# Decisions follow the repository contract in AGENTS.md, not client trust. +# A trusted project still keeps live systems behind an operator decision: # -# Prompt boundaries are retained for: -# - production/application deploys -# - live logs and broad account-changing Cloudflare operations -# - generic credential wrappers capable of executing arbitrary commands -# - publication, workflow dispatch, and releases +# allow named local or read-only inspection: versions, local stack +# status and startup, and listing output +# prompt anything that can write to, authenticate against, or read data +# or secrets out of a live project or account, plus every wrapper +# that can execute a caller-supplied command +# forbidden the destructive Git operations at the end of this file # -# The listed destructive Git prefixes remain forbidden. These rules do not -# cover every possible invocation; never change command form to evade a stop. +# A command that matches no rule is ordinary local work and falls through to +# the session's normal approval policy; unmatched is not an allow. +# +# The most restrictive match wins (forbidden > prompt > allow), so every allow +# prefix here is written to be exact and is never overlapped by a prompt +# prefix. Do not add a broad prompt that swallows a narrow allow, and do not +# add a narrow allow expecting it to override a broad prompt. +# +# An allow decision removes a rule-level prompt; it never expands the authority +# the user granted for the task. AGENTS.md rule 4 reserves deploys, remote +# database writes, migrations, and secret changes for explicit user authority, +# and rule 5 keeps live type generation out of ordinary local validation. +# +# These are prefixes, not exhaustive coverage of every invocation; never change +# command form to evade a stop. # # Codex loads project-local .codex/rules/ only for a trusted project. # Workspace package names and packages/ paths are fixed sets, so @@ -42,7 +54,7 @@ prefix_rule( "pnpm web:deploy", ], not_match=[ - "pnpm db:types", + "pnpm build", "pnpm lint", "pnpm test:agent", ], @@ -66,32 +78,12 @@ prefix_rule( "pnpm run web:deploy", ], not_match=[ - "pnpm run db:types", + "pnpm run test:agent", "pnpm run lint", "pnpm run build", ], ) -# Database type generation is allowed by policy but still needs live-schema -# task authority; it is excluded from ordinary local validation. -prefix_rule( - pattern=["pnpm", "db:types"], - decision="allow", - justification="Database type generation is routine trusted-agent development work.", - match=[ - "pnpm db:types", - ], -) - -prefix_rule( - pattern=["pnpm", "run", "db:types"], - decision="allow", - justification="Database type generation is routine trusted-agent development work.", - match=[ - "pnpm run db:types", - ], -) - prefix_rule( pattern=[ "pnpm", @@ -172,19 +164,45 @@ prefix_rule( "deploy", "versions", "rollback", + "delete", "secret", "tail", - "pages", - "d1", - "r2", "login", + "logout", ], ], decision="prompt", - justification="Direct Wrangler account operations remain explicit review boundaries.", + justification="Direct Wrangler deploys, version rollouts, rollbacks, secret changes, live log tails, and account authentication remain explicit review boundaries.", match=[ - "wrangler secret put X", "wrangler deploy", + "wrangler secret put EXAMPLE_SECRET_NAME", + "wrangler tail", + ], + not_match=[ + "wrangler types", + "wrangler dev", + "wrangler --version", + ], +) + +prefix_rule( + pattern=[ + "wrangler", + [ + "d1", + "hyperdrive", + "kv", + "pages", + "queues", + "r2", + "vectorize", + ], + ], + decision="prompt", + justification="Product namespaces outside Dicee's committed architecture are prompts so an invocation cannot create or change account resources the project does not use.", + match=[ + "wrangler r2 bucket create example-bucket", + "wrangler kv namespace list", ], not_match=[ "wrangler types", @@ -237,18 +255,6 @@ prefix_rule( ], ) -prefix_rule( - pattern=["./scripts/with-dicee-infisical-auth.sh"], - decision="prompt", - justification="Resolves operator credentials and can execute a supplied command.", - match=[ - "./scripts/with-dicee-infisical-auth.sh dev -- true", - ], - not_match=[ - "./scripts/quality-gate.sh", - ], -) - prefix_rule( pattern=["./scripts/with-dicee-elevenlabs-local.sh"], decision="prompt", @@ -266,14 +272,13 @@ prefix_rule( [ "scripts/with-dicee-cloudflare.sh", "scripts/with-dicee-elevenlabs-local.sh", - "scripts/with-dicee-infisical-auth.sh", ], ], decision="prompt", justification="Credential-resolving project wrappers can execute supplied commands.", match=[ "scripts/with-dicee-cloudflare.sh -- wrangler deploy", - "scripts/with-dicee-infisical-auth.sh dev -- true", + "scripts/with-dicee-elevenlabs-local.sh -- pnpm audio:gen", ], not_match=[ "scripts/public-safety-scan.sh", @@ -288,8 +293,6 @@ prefix_rule( "./scripts/with-dicee-cloudflare.sh", "scripts/with-dicee-elevenlabs-local.sh", "./scripts/with-dicee-elevenlabs-local.sh", - "scripts/with-dicee-infisical-auth.sh", - "./scripts/with-dicee-infisical-auth.sh", ], ], decision="prompt", @@ -326,6 +329,7 @@ prefix_rule( justification="Wrangler through a workspace package can reach the Cloudflare account.", match=[ "pnpm --filter @dicee/cloudflare-do exec wrangler deploy", + "pnpm -F @dicee/web exec wrangler deploy", ], not_match=[ "pnpm --filter @dicee/cloudflare-do exec vitest run", @@ -352,6 +356,7 @@ prefix_rule( justification="Wrangler through a workspace package can reach the Cloudflare account.", match=[ "pnpm --dir packages/cloudflare-do exec wrangler deploy", + "pnpm -C packages/web exec wrangler secret put EXAMPLE_SECRET_NAME", ], not_match=[ "pnpm --dir packages/web exec biome check", @@ -361,100 +366,226 @@ prefix_rule( # --------------------------------------------------------------------------- -# Supabase +# Supabase: read-only and local inspection # --------------------------------------------------------------------------- # -# Dicee deliberately allows the listed Supabase prefixes at the policy layer. -# This includes linked-project inspection and mutation, migrations, dumps, -# storage operations, functions, secrets, login/linking, and type generation. -# An allow decision does not authorize an operation outside the user's task. +# One binary addresses the local stack and the linked hosted project, usually +# separated only by a flag, so the split below is by subcommand rather than by +# trust. Only these named prefixes are allowed; keep them exact so no prompt +# prefix below overlaps them. prefix_rule( - pattern=[ - "supabase", - [ - "db", - "migration", - "link", - "storage", - "projects", - "login", - "secrets", - "functions", - "gen", - ], - ], + pattern=["supabase", ["--version", "status", "start"]], decision="allow", - justification="Dicee is a trusted agentic project; agents may operate Supabase autonomously.", + justification="Version output and the local stack's status and startup do not reach the linked project.", match=[ - "supabase db dump --linked -f backup.sql", - "supabase db push --linked --dry-run", + "supabase --version", + "supabase status", + "supabase start", + ], + not_match=[ "supabase db push --linked", + "supabase login", + ], +) + +prefix_rule( + pattern=["supabase", ["migration", "projects", "functions"], "list"], + decision="allow", + justification="Migration, project, and function listings are read-only inventory output.", + match=[ "supabase migration list --linked", + "supabase projects list", + "supabase functions list --project-ref example", + ], + not_match=[ + "supabase migration repair --status applied 20260913000001 --linked", + "supabase functions deploy example-function", + "supabase projects create example-project", + ], +) + +prefix_rule( + pattern=["supabase", "db", "diff", "--help"], + decision="allow", + justification="Help output for a database subcommand opens no connection.", + match=[ + "supabase db diff --help", + ], + not_match=[ + "supabase db push --linked", + "supabase db query --help", + ], +) + + +# --------------------------------------------------------------------------- +# Supabase: live project operations +# --------------------------------------------------------------------------- + +# The decision is taken at the subcommand, not at a flag: every one of these +# can be aimed at the linked project by a flag that may appear anywhere in the +# argument list, so a flag-position rule would only look exact. The local reset +# used by the pgTAP workflow therefore costs one approval as well. +prefix_rule( + pattern=["supabase", "db", ["push", "query", "pull", "dump", "lint", "reset"]], + decision="prompt", + justification="These subcommands write to, query, reset, or export the database the CLI is pointed at, which is the linked production project whenever it is targeted.", + match=[ + "supabase db push --linked", + "supabase db push --linked --dry-run", + "supabase db query --help", + "supabase db dump --linked -f backup.sql", + "supabase db reset --linked", + ], + not_match=[ + "supabase db diff --help", + "supabase status", + ], +) + +prefix_rule( + pattern=["supabase", "migration", ["repair", "apply", "up", "fetch", "squash"]], + decision="prompt", + justification="Applying, repairing, or rewriting migration history changes the state of the database it is pointed at; AGENTS.md rule 4 reserves that for explicit user authority.", + match=[ "supabase migration repair --status applied 20260913000001 --linked", + "supabase migration up --linked", + ], + not_match=[ + "supabase migration list --linked", + "supabase migration new example_change", + ], +) + +prefix_rule( + pattern=["supabase", ["link", "login"]], + decision="prompt", + justification="Linking selects which hosted project every later command targets, and login stores an account credential.", + match=[ "supabase link --project-ref example", - "supabase storage cp --help", - "supabase projects list", "supabase login", - "supabase functions list --project-ref example", - "supabase functions delete aggregate-game-stats --project-ref example", - "supabase gen types typescript", ], not_match=[ "supabase status", - "supabase start", - "supabase --version", + "supabase projects list", ], ) prefix_rule( - pattern=["supabase", ["status", "start", "--version"]], - decision="allow", - justification="Local Supabase inspection and startup are autonomous operations.", + pattern=["supabase", "secrets"], + decision="prompt", + justification="Every secrets subcommand reads or changes hosted project secrets.", match=[ + "supabase secrets list", + "supabase secrets unset EXAMPLE_SECRET_NAME", + ], + not_match=[ "supabase status", - "supabase start", - "supabase --version", + "supabase projects list", ], ) +prefix_rule( + pattern=["supabase", "storage", ["cp", "mv", "rm"]], + decision="prompt", + justification="Copy, move, and remove write to or delete from hosted storage.", + match=[ + "supabase storage cp ./example.png ss:///example-bucket/example.png", + "supabase storage rm ss:///example-bucket/example.png", + ], + not_match=[ + "supabase storage ls", + ], +) + +prefix_rule( + pattern=["supabase", "functions", ["deploy", "delete"]], + decision="prompt", + justification="Deploying or deleting an Edge Function changes hosted project state.", + match=[ + "supabase functions deploy example-function", + "supabase functions delete example-function", + ], + not_match=[ + "supabase functions list --project-ref example", + "supabase functions new example-function", + ], +) + +prefix_rule( + pattern=["supabase", "projects", ["create", "delete", "api-keys"]], + decision="prompt", + justification="Creating or deleting a project changes the account, and api-keys prints project credentials.", + match=[ + "supabase projects create example-project", + "supabase projects api-keys --project-ref example", + ], + not_match=[ + "supabase projects list", + ], +) + +prefix_rule( + pattern=["supabase", "gen"], + decision="prompt", + justification="Type generation reads the live schema of the linked project, which AGENTS.md rule 5 keeps out of an ordinary local gate.", + match=[ + "supabase gen types typescript", + ], + not_match=[ + "supabase status", + "supabase migration list --linked", + ], +) + + +# --------------------------------------------------------------------------- +# Supabase through wrappers and project scripts +# --------------------------------------------------------------------------- +# +# A wrapper hides which subcommand runs, so these forms are not decomposed: +# each carries the boundary of the most privileged Supabase command it could +# reach. Invoke the CLI directly when a read-only prefix above should apply. -# Supabase via plain pnpm exec. prefix_rule( pattern=["pnpm", "exec", "supabase"], - decision="allow", - justification="Trusted agents may invoke Supabase through pnpm exec.", + decision="prompt", + justification="A wrapped Supabase invocation can reach the linked project; the specific subcommand is confirmed at the prompt.", match=[ + "pnpm exec supabase db push --linked", "pnpm exec supabase migration list --linked", - "pnpm exec supabase db push --linked --dry-run", + ], + not_match=[ + "pnpm exec biome check", ], ) - -# Supabase via npx. prefix_rule( pattern=["npx", "supabase"], - decision="allow", - justification="Trusted agents may invoke Supabase through npx.", + decision="prompt", + justification="A wrapped Supabase invocation can reach the linked project; the specific subcommand is confirmed at the prompt.", match=[ "npx supabase migration list --linked", ], + not_match=[ + "npx vitest run", + ], ) - -# Supabase via mise when the repo-pinned CLI version is required. prefix_rule( pattern=["mise", "exec", "--", "supabase"], - decision="allow", - justification="Trusted agents may invoke the repo-pinned Supabase CLI through mise.", + decision="prompt", + justification="The repo-pinned Supabase CLI reaches the same linked project as the direct binary, and the wrapper hides the subcommand.", match=[ + "mise exec -- supabase db push --linked", "mise exec -- supabase migration list --linked", - "mise exec -- supabase db push --linked --dry-run", + ], + not_match=[ + "mise exec -- bun run packages/web/src/tools/akg/mcp/server.ts", ], ) - -# Supabase through workspace package filters. prefix_rule( pattern=[ "pnpm", @@ -468,11 +599,11 @@ prefix_rule( "exec", "supabase", ], - decision="allow", - justification="Trusted Dicee agents may invoke Supabase through workspace packages.", + decision="prompt", + justification="Supabase through a workspace package reaches the linked project and hides the subcommand.", match=[ - "pnpm --filter @dicee/web exec supabase migration list --linked", - "pnpm -F @dicee/web exec supabase db push --linked --dry-run", + "pnpm --filter @dicee/web exec supabase db push --linked", + "pnpm -F @dicee/simulation exec supabase gen types typescript", ], not_match=[ "pnpm --filter @dicee/cloudflare-do exec vitest run", @@ -495,11 +626,11 @@ prefix_rule( "exec", "supabase", ], - decision="allow", - justification="Trusted Dicee agents may invoke Supabase through workspace packages.", + decision="prompt", + justification="Supabase through a workspace package reaches the linked project and hides the subcommand.", match=[ - "pnpm --dir packages/web exec supabase migration list --linked", - "pnpm -C packages/web exec supabase db push --linked --dry-run", + "pnpm --dir packages/web exec supabase db push --linked", + "pnpm -C packages/analysis exec supabase migration up", ], not_match=[ "pnpm --dir packages/web exec biome check", @@ -507,19 +638,48 @@ prefix_rule( ], ) +prefix_rule( + pattern=["pnpm", "db:types"], + decision="prompt", + justification="Generates types from the live schema of the linked Supabase project, which is not part of ordinary local validation.", + match=[ + "pnpm db:types", + ], + not_match=[ + "pnpm build", + "pnpm lint", + ], +) + +prefix_rule( + pattern=["pnpm", "run", "db:types"], + decision="prompt", + justification="Generates types from the live schema of the linked Supabase project, which is not part of ordinary local validation.", + match=[ + "pnpm run db:types", + ], + not_match=[ + "pnpm run build", + "pnpm run lint", + ], +) + # --------------------------------------------------------------------------- # 1Password # --------------------------------------------------------------------------- -# Direct secret reads are allowed for authorized tasks. The example argument is -# deliberately not a resolvable credential reference; examples are never run. +# Resolving a credential is an operator decision even for a read. The example +# argument is deliberately not a resolvable reference; examples are never run. prefix_rule( - pattern=["op", "read"], - decision="allow", - justification="Trusted Dicee agents may resolve project credentials from 1Password.", + pattern=["op", ["read", "item", "document", "inject"]], + decision="prompt", + justification="Each of these reaches a live credential in the operator's vault, which AGENTS.md rule 3 reserves for an explicitly authorized task. `op item get` returns the same secret as `op read`, so they share one boundary.", match=[ "op read CREDENTIAL_REFERENCE_PLACEHOLDER", + "op item get your-cloudflare-item --fields api-token", + "op document get your-document", + "op inject -i template.tpl", ], not_match=[ "op whoami", @@ -534,6 +694,9 @@ prefix_rule( match=[ "op whoami", ], + not_match=[ + "op read CREDENTIAL_REFERENCE_PLACEHOLDER", + ], ) # Generic op run can wrap any executable, including deploy or destructive diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 91edd74..0073e62 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -2,8 +2,11 @@ "mcpServers": { "akg": { "type": "stdio", - "command": "bun", + "command": "mise", "args": [ + "exec", + "--", + "bun", "run", "${workspaceFolder}/packages/web/src/tools/akg/mcp/server.ts" ], diff --git a/.mcp.json b/.mcp.json index ccb368c..d1425c7 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,8 +1,11 @@ { "mcpServers": { "akg": { - "command": "bun", + "command": "mise", "args": [ + "exec", + "--", + "bun", "run", "packages/web/src/tools/akg/mcp/server.ts" ], diff --git a/AGENTS.md b/AGENTS.md index ed8f01f..7d1abf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ Before editing `packages/web` or `packages/cloudflare-do`, read that package's ` 1. Read the nearest source, test, and current config before editing. Code and generated types outrank docs; fix a doc that disagrees. 2. Keep local evidence, committed configuration, and live Cloudflare/Supabase state distinct. Never claim a deploy, migration, secret change, or production check happened without direct evidence. A live claim needs a first-hand readback from the current session (request, result, UTC time); citing status docs, prior audits, or hub records as confirmation is circular. -3. Never expose or commit secrets, account identifiers, or project refs. Authorized Cloudflare operations run through `./scripts/with-dicee-cloudflare.sh -- `; Supabase operations use the Supabase CLI under explicit operator authority. Run `./scripts/check-1password-setup.sh` only for an explicitly authorized task that needs operator credentials. Infisical is retired: add no new Infisical usage; the remaining scripts are removed per `docs/roadmap.md` section 4. +3. Never expose or commit secrets, account identifiers, or project refs. Authorized Cloudflare operations run through `./scripts/with-dicee-cloudflare.sh -- `; Supabase operations use the Supabase CLI under explicit operator authority. Run `./scripts/check-1password-setup.sh` only for an explicitly authorized task that needs operator credentials. Infisical is retired and its scripts and metadata names are gone: add no new Infisical usage. 4. Deployment, remote database writes, migrations, secret changes, destructive Git operations, and publication require explicit user authority. Dry runs and local validation are safe defaults. 5. Do not regenerate Supabase types as part of an ordinary local gate; that is an authenticated, live-schema operation. 6. Cloudflare work starts at `docs/cloudflare.md`. Committed architecture is the `dicee-web` Worker (SvelteKit, Workers Static Assets) with a `GAME_WORKER` service binding to the `dicee` Worker with SQLite Durable Objects, plus Supabase; `dicee-web` holds the `dicee.games` custom domain and the Pages project is deleted, so web releases target `dicee-web` and there is no Pages rollback. D1, R2, KV, further Worker splits, and OpenTofu are not current architecture; organization governance and infrastructure as code arrive only through `docs/roadmap.md`. diff --git a/docs/development/agent-clients.md b/docs/development/agent-clients.md index bf0df56..e488b21 100644 --- a/docs/development/agent-clients.md +++ b/docs/development/agent-clients.md @@ -5,7 +5,17 @@ - [AGENTS.md](../../AGENTS.md) is the repository contract for every client. - [packages/web/AGENTS.md](../../packages/web/AGENTS.md) and [packages/cloudflare-do/AGENTS.md](../../packages/cloudflare-do/AGENTS.md) add package rules. - Client files add only real behavioral differences, never copies of the contract. -- Claude Code and Codex started at the repository root do not load nested `AGENTS.md` files on their own. Read the package file before editing that package. +- No client loads nested `AGENTS.md` by default. Claude Code reads `CLAUDE.md` and never `AGENTS.md` itself; Codex walks the repository root down to the working directory and stops there; VS Code keeps nested files behind an experimental, off-by-default setting. Read the package file before editing that package. + +## Supported clients + +Minimums, not pins. A client patch release moves faster than this document, so the installed build is a runtime fact rather than a recorded one: `pnpm agent:doctor` reports it, and reports it as an installed build rather than a claim about the vendor's channel. An installed build ahead of, or behind, public stable is a local fact and never a repository requirement. + +| Client | Supported minimum | +|---|---| +| VS Code | 1.137 stable; never require an Insiders-only feature | +| Claude Code | 2.1.272 public stable | +| Codex CLI | 0.154.0 stable; the 0.155 series is prerelease and is not a target | ## Client matrix @@ -13,10 +23,13 @@ |---|---|---|---| | Claude Code | `CLAUDE.md`, which imports `@AGENTS.md` | `.mcp.json`, `.claude/settings.json`, skill symlinks in `.claude/skills/` | [memory](https://code.claude.com/docs/en/memory), [skills](https://code.claude.com/docs/en/skills), [permissions](https://code.claude.com/docs/en/permissions), [MCP](https://code.claude.com/docs/en/mcp) | | Codex | `AGENTS.md` from the root down to the working directory | `.codex/config.toml`, `.codex/agents/`, `.codex/rules/`, `.agents/skills/` (trusted projects only) | [AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md), [rules](https://learn.chatgpt.com/docs/agent-configuration/rules), [config](https://learn.chatgpt.com/docs/config-file/config-reference), [subagents](https://learn.chatgpt.com/docs/agent-configuration/subagents), [skills](https://learn.chatgpt.com/docs/build-skills) | +| VS Code | root `AGENTS.md` (`chat.useAgentsMdFile`, default on) and `CLAUDE.md` (`chat.useClaudeMdFile`, default on) | `.vscode/mcp.json`, `.vscode/settings.json` | [custom instructions](https://code.visualstudio.com/docs/agent-customization/custom-instructions), [AI settings](https://code.visualstudio.com/docs/agents/reference/ai-settings) | | Gemini CLI | `AGENTS.md` through `.gemini/settings.json` | none beyond the context file | [context files](https://geminicli.com/docs/cli/gemini-md/), [configuration](https://geminicli.com/docs/reference/configuration/) | -| GitHub Copilot | `AGENTS.md` (root and nested); IDE code review reads `.github/copilot-instructions.md` | `.mcp.json` for Copilot CLI after folder trust | [repository instructions](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions), [support matrix](https://docs.github.com/en/copilot/reference/custom-instructions-support), [CLI MCP](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers) | +| GitHub Copilot | `AGENTS.md` in Copilot CLI, the coding agent, GitHub.com code review and VS Code chat; `.github/copilot-instructions.md` on the surfaces that do not read it | `.mcp.json` for Copilot CLI after folder trust | [repository instructions](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions), [support matrix](https://docs.github.com/en/copilot/reference/custom-instructions-support), [CLI MCP](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers) | | Cursor | `AGENTS.md` (root and nested) | `.cursor/mcp.json`; skills from `.agents/skills/` and `.claude/skills/` | [rules](https://cursor.com/docs/context/rules), [MCP](https://cursor.com/docs/context/mcp), [skills](https://cursor.com/docs/context/skills) | +VS Code loads `AGENTS.md` and `CLAUDE.md` together by default, and Dicee's `CLAUDE.md` imports `@AGENTS.md`, so the contract lands in context twice. Turn `chat.useClaudeMdFile` off in user settings; Dicee commits no project override for it. + The following are retired and must not be reintroduced: Windsurf and Cascade files, root client files other than `AGENTS.md` and `CLAUDE.md`, Cursor project rules, the Copilot MCP template, and path-scoped Copilot instruction files. `pnpm lint:docs` also refuses a `hooks` key in `.claude/settings.json`, and refuses tracked files in the Claude commands and hooks directories or a hooks directory under scripts. That is a ratchet, not a judgement on the features: those paths had accumulated roughly 3,500 lines of stale generated guidance and session scripts, which one 2026-09-13 change removed in favour of the two portable skills. Reversing it is an owner decision, and the argument has to be a concrete need rather than "the client supports it". Claude agent and rule directories are deliberately not part of the ratchet. @@ -25,7 +38,7 @@ The following are retired and must not be reintroduced: Windsurf and Cascade fil | Server | Transport | Auth | Default | |---|---|---|---| -| `akg` | stdio, `bun run packages/web/src/tools/akg/mcp/server.ts` | none (local) | enabled | +| `akg` | stdio, `mise exec -- bun run packages/web/src/tools/akg/mcp/server.ts` | none (local) | enabled | | `cloudflare-docs` | HTTP | none | enabled | | `cloudflare-api` | HTTP | client OAuth | opt-in (`.mcp.json` only) | | `supabase` | HTTP, `read_only=true`, `features=database,docs,functions` | client OAuth | opt-in (`.mcp.json` only) | @@ -36,11 +49,12 @@ The following are retired and must not be reintroduced: Windsurf and Cascade fil - **Supabase project scope.** The URL reads `DICEE_SUPABASE_PROJECT_REF`. Set it as a non-secret export in the ignored `.envrc.local.nonsecret`, which `.envrc` sources. When the variable is unset, Claude Code keeps the literal placeholder and the server rejects it, so the entry fails closed. No project ref is ever committed. - **Cursor.** `.cursor/mcp.json` lists only `akg` (`"type": "stdio"`) and `cloudflare-docs` (`url`). An authorized opt-in adds `cloudflare-api` or `supabase` to that project-native file with client OAuth; never add Dicee servers to user-global configuration. Use Cursor's `${env:DICEE_SUPABASE_PROJECT_REF}` interpolation for the Supabase project parameter (Claude uses `${DICEE_SUPABASE_PROJECT_REF}`). Keep provider identifiers out of committed configuration and leave `supabase` off until its non-secret project variable is set. - **Codex.** `.codex/config.toml` declares `akg` and `cloudflare-docs` only. -- **VS Code.** `.vscode/mcp.json` uses native top-level `servers` for `akg` and `cloudflare-docs` only. The local server starts through `mise exec` so it uses Dicee's pinned Bun. Workspace trust and server startup remain operator decisions. See the [editor setup](../../.vscode/README.md) for settings, recommendations and tasks. +- **Launcher.** Every client starts `akg` through `mise exec -- bun`, so it always runs on the Bun pinned in `.mise.toml` rather than whatever is first on that client's PATH. `pnpm agent:doctor` checks the four files agree; change them together. +- **VS Code.** `.vscode/mcp.json` uses native top-level `servers` for `akg` and `cloudflare-docs` only. Workspace trust and server startup remain operator decisions. See the [editor setup](../../.vscode/README.md) for settings, recommendations and tasks. ## Skills -- Portable skills live in `.agents/skills//SKILL.md` (`dicee-verify`, `akg-boundaries`). Each file needs `name`, matching its directory, and `description`. `pnpm lint:docs` enforces both fields and a 60-line cap per skill. +- Portable skills live in `.agents/skills//SKILL.md` (`dicee-verify`, `akg-boundaries`). Each file needs `name`, matching its directory, and `description`. `pnpm lint:docs` enforces both fields and a 60-line cap per skill; Claude Code itself treats `name` as optional, so the gate is the stricter rule. - Claude Code does not read `.agents/skills/`, so `.claude/skills/` is a symlink to `../../.agents/skills/`. Edit the target, never the link. - Codex reads `.agents/skills/` directly. - Cursor reads both directories, so it lists each skill twice. This is cosmetic. @@ -50,10 +64,11 @@ The following are retired and must not be reintroduced: Windsurf and Cascade fil - **Trust.** Project `.codex/config.toml`, `.codex/rules/`, and custom agents load only after the project is trusted. Personal model, auth, approval, and sandbox choices stay in user config. - **Config.** `[agents]` sets `max_concurrent_threads_per_session = 4` and `max_depth = 1`. The read-only `reviewer` and `researcher` agents in `.codex/agents/` are discovered automatically. - **Launch.** Start Codex from the repository root: the `akg` entry uses repo-relative paths and sets no `cwd`. -- **Rules.** `.codex/rules/dicee.rules` deliberately allows the listed Supabase forms, `pnpm db:types`, `op read`, and `op whoami`. Deploys, live logs, listed Wrangler account commands, credential wrappers, `op run`, `git push`, and workflow/release commands retain `prompt`. The listed `git reset --hard`, `git clean`, and force-push prefixes are `forbidden`. These are prefix rules, not exhaustive coverage of every invocation. +- **Rules.** `.codex/rules/dicee.rules` allows read-only inspection — `supabase --version`, `status`, `start`, `migration list --linked`, project and function inventory, `op whoami`. Anything that writes to a linked project or reaches a live credential is `prompt`: `db push`, `db pull`, `db dump`, `db reset`, `migration repair/up/apply`, `link`, `login`, `secrets`, `functions deploy/delete`, `gen types`, `pnpm db:types`, `op read`/`op item`, credential wrappers, deploys, live logs, Wrangler account commands, `git push`, and workflow/release commands. The listed `git reset --hard`, `git clean`, and force-push prefixes are `forbidden`. These are prefix rules matched by token position, so a global flag placed before a subcommand escapes them; never change command form to evade a stop. - **Permission and authority.** An `allow` decision removes a rule-level prompt; it does not expand the user's task authorization, bypass operator stop points in `docs/roadmap.md` section 1, or make live Supabase type generation part of ordinary validation. Resolve secrets only when the authorized operation needs them. Other active policy layers and the session's approval mode still apply. If execution is rejected, report the exact barrier; do not change command form or access policy to evade it. - **Checking a command.** `codex execpolicy check --rules .codex/rules/dicee.rules -- git push -f origin main` evaluates the supplied file without executing the example. It prints JSON with `matchedRules` and a top-level `decision`; the most restrictive match wins (`forbidden` > `prompt` > `allow`). An unmatched command prints empty `matchedRules` and no `decision`. This file-only check does not prove what the running session permits. `bash scripts/tests/codex-rules.test.sh` validates literal declarations and requires explicit decisions, justifications, and nonempty `match` examples; `not_match` is optional. It also checks decisions where `codex` is installed (CI has none). See the [official OpenAI rules documentation](https://learn.chatgpt.com/docs/agent-configuration/rules). -- **Filtered workspace commands.** The workspace package set is fixed. The rules enumerate its names for `pnpm --filter exec ...` and directory forms, with separate decisions for Wrangler (`prompt`) and Supabase (`allow`). +- **Filtered workspace commands.** The workspace package set is fixed. The rules enumerate its names for `pnpm --filter exec ...` and directory forms, with separate decisions for Wrangler and Supabase. +- **Known friction.** `supabase db reset --local` prompts, because a prefix rule cannot tell `--local` from `--linked` at that position and a destructive reset defaults to the safer decision. The local pgTAP loop takes one approval. ## Gemini @@ -61,10 +76,25 @@ The following are retired and must not be reintroduced: Windsurf and Cascade fil ## Copilot -- The coding agent, Copilot CLI and GitHub.com code review read `AGENTS.md`; nested package files apply for the coding agent. -- VS Code and Visual Studio code review read only `.github/copilot-instructions.md`, a short pointer back to the contract. -- Copilot CLI loads project MCP from `.mcp.json` after folder trust; no user-level template is merged. -- Not verified: whether GitHub.com code review honours nested package `AGENTS.md` files. +- Copilot CLI, the coding agent, GitHub.com code review and VS Code chat read `AGENTS.md`. +- `.github/copilot-instructions.md` survives for the surfaces that do not: Copilot code review in VS Code and in Visual Studio, and Visual Studio chat. Dicee keeps it to a few lines pointing back at the contract. +- Do not restate the contract there and do not add path-scoped Copilot instruction files. VS Code combines every always-on instruction source it finds and guarantees no order between them, so a second copy is a conflict risk, not redundancy. +- Settings-based code and test generation instructions are deprecated in favour of file-based instructions; do not reintroduce them in `.vscode/settings.json`. +- **Project MCP.** Copilot CLI walks from the working directory up to the repository root loading `.mcp.json`, in interactive mode only after folder trust (prompt mode has a documented environment-variable override); project definitions beat the user-level file at `~/.copilot/mcp-config.json`. The system-config project contract lists that filename as a Copilot CLI *project* file; current GitHub documentation places it in the home directory, so `.mcp.json` is the correct project file and Dicee commits no Copilot-specific MCP file. +- Not verified: which Copilot surfaces honour nested `AGENTS.md`. GitHub documents nesting generically, and VS Code gates it behind an experimental setting, so the package-file instruction above stands either way. + +## Models + +Guidance only. Model, reasoning effort, auth, approval mode, and sandbox are personal user policy and never belong in Dicee's tracked configuration: `.claude/settings.json` carries no `model` key and `.codex/config.toml` says so explicitly. Prefer an alias over a dated model id so this section does not rot, and do not target a model approaching retirement. + +| Work | Claude Code | Codex | +|---|---|---| +| Ordinary implementation | `sonnet` (currently Sonnet 5) | `gpt-5.6-sol`, or the client's current default; do not repo-pin one | +| Deep architecture, adversarial review | `opus` (currently Opus 5) | `gpt-6-astra`, normally high effort | + +- Raise Codex effort past high (`xhigh`) only when the task justifies the cost. It is not a default, and Astra at lower effort often beats the previous model at high. +- Reproducible evaluations, headless runs, and `codex exec` record client version, model, reasoning effort, commit, and config provenance in the evidence. Unpinned automation has been observed changing behavior as vendor defaults move, so "we ran Codex" without those five facts is not evidence. +- Alias mappings are the vendor's, not ours: see the [Claude Code model aliases](https://code.claude.com/docs/en/model-config) table and the Codex [config reference](https://learn.chatgpt.com/docs/config-file/config-reference) for the effort values the installed client accepts. ## Prohibited patterns @@ -78,6 +108,8 @@ The following are retired and must not be reintroduced: Windsurf and Cascade fil Static checks, safe anywhere: ```bash +pnpm lint:docs +pnpm agent:doctor python3 -m json.tool .mcp.json >/dev/null python3 -m json.tool .cursor/mcp.json >/dev/null jq -e '[.mcpServers[] | select(has("url"))] | all(.type=="http")' .mcp.json @@ -89,6 +121,7 @@ Operator checks in interactive sessions after a client-surface change: - Claude Code: `/context` and `/skills` show `CLAUDE.md`, `AGENTS.md` and the two skills; `claude mcp list` shows `akg` and `cloudflare-docs` enabled. - Codex: trust the project, then `codex mcp list` from the repository root lists `akg` and `cloudflare-docs`. +- VS Code: trust the workspace, then the chat context shows `AGENTS.md` and the two MCP servers start. - Gemini CLI: `/memory show` includes `AGENTS.md`. - Cursor: the skills list shows `dicee-verify` and `akg-boundaries` (twice). diff --git a/docs/development/organization-alignment.md b/docs/development/organization-alignment.md index 1d07b43..1879264 100644 --- a/docs/development/organization-alignment.md +++ b/docs/development/organization-alignment.md @@ -154,7 +154,7 @@ The relevant local control is [cloudflare-config-audit.mjs](../../scripts/cloudf ## Adoption deliverable and order -Use the existing roadmap, not a second phase system. Its credential prerequisites remain: Supabase key migration, HS256 removal and Infisical retirement before ownership changes. Current application/privacy safety work keeps its existing priority. Documentation and source design can proceed while those operations remain pending. +Use the existing roadmap, not a second phase system. Its credential prerequisites remain: Supabase key migration and HS256 removal before ownership changes. Current application/privacy safety work keeps its existing priority. Documentation and source design can proceed while those operations remain pending. For the organization-move item, prepare one reviewable intake that answers: diff --git a/docs/roadmap.md b/docs/roadmap.md index b5cc7db..ad22832 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -44,7 +44,6 @@ Agent-safe to build. Shipping any of it is an ordinary authorized release, not a Agent-safe unless noted. -- Retire the Infisical scripts, metadata names and .infisical.json handling — `scripts/with-dicee-infisical-auth.sh`, the `DICEE_*INFISICAL*` names in `scripts/lib/dicee-operator-metadata.sh` and its template, the `scripts/check-1password-setup.sh` checks and the wrapper and Codex-rule test cases are gone; the publication scan keeps its .infisical.json and private-hostname patterns as residual guards. Operator revokes the identities (status action 13). - History secret scan (pinned Gitleaks; the publication scan checks only candidate files) and a workflow policy check — both run in CI. - AKG graph drift gate: `pnpm akg:check` fails when discovery differs from the committed graph — `git diff --exit-code` on the graph after discovery. - Remove or restore the dangling `web:analyze-logs` scripts: the log-analyzer tool directory under packages/web is gitignored, so a clean clone has no CLI entry file — the script runs from a clean clone or is gone. @@ -76,7 +75,7 @@ Agent-safe, in order. Gate for each: `pnpm validate` and `pnpm akg:check`. ## 7. Organization move with governance and IaC -Near-term direction (status decision 9). The GitHub repository transfer and local checkout move are complete, and the application now runs on the committed two-Worker architecture, so discovery describes live resources rather than a planned shape. Remaining provider/infrastructure ownership changes and infrastructure adoption still wait for the Supabase key migration, HS256 removal and Infisical retirement. Section 1 keeps its operator stop points; this section does not advance them. +Near-term direction (status decision 9). The GitHub repository transfer and local checkout move are complete, and the application now runs on the committed two-Worker architecture, so discovery describes live resources rather than a planned shape. Remaining provider/infrastructure ownership changes and infrastructure adoption still wait for the Supabase key migration and the HS256 removal. Section 1 keeps its operator stop points; this section does not advance them. The [selected Cloudflare strategy](cloudflare.md#governance-strategy) preserves the running application shape and separates Jefahnierocks service governance from shared-account stewardship. The [organization alignment guide](development/organization-alignment.md) records the completed GitHub/local move and remains the proposal for the remaining provider and infrastructure boundaries. The repository transfer does not establish Cloudflare, Supabase, Google or infrastructure execution authority. diff --git a/docs/status.md b/docs/status.md index e30ffcf..a60b62c 100644 --- a/docs/status.md +++ b/docs/status.md @@ -21,7 +21,7 @@ Next work: [roadmap.md](roadmap.md). Cloudflare: [cloudflare.md](cloudflare.md). 1. **Durable Objects.** Keep the legacy `migrations` v1 (`GameRoom`) and v2 (`GlobalLobby`) with `new_sqlite_classes`. Adopt declarative `exports` only for a concrete need, as a standalone operator deploy. `dicee` is the one game backend, reached only through the `GAME_WORKER` binding on `dicee-web`; its SQLite `GameRoom` and `GlobalLobby` namespaces are at migration tag v2 and the release changed no tag. Legacy namespace pairs on other scripts are cleanup candidates, not the production owner. 2. **MCP.** `akg` (stdio) and unauthenticated `cloudflare-docs` are enabled. `cloudflare-api` and read-only Supabase are opt-in OAuth servers. No bearer-token wrappers or credential bridges. -3. **Secrets.** Infisical is retired as a provider choice: no new usage, and the remaining scripts and metadata names come out through [roadmap section 4](roadmap.md#4-repository-hygiene) with the identities revoked in action 13. CI reads GitHub Environment secrets; local operator commands resolve 1Password secrets per command. +3. **Secrets.** Infisical is retired. Its launcher, metadata names, credential checks and command-policy rules are out of the repository; the publication scan keeps its Infisical-config and private-hostname filename patterns as residual guards, and action 13 revokes the identities. CI reads GitHub Environment secrets; local operator commands resolve 1Password secrets per command. 4. **Database.** `20260913000001` was applied alone, then `20260914000001` and `20260914000002` together with the release. `20260913000002` applies only after a production deploy that includes the profile visibility opt-in control (action 4). 5. **Wrangler.** Stay on the miniflare 4 line: the repository pins wrangler 4.113.0, and both Workers now run `compatibility_date` 2026-07-21. Dependabot cites this decision number. 6. **Homes.** This file is the status of record; [roadmap.md](roadmap.md) is the only sequence of work; git history is the archive. diff --git a/package.json b/package.json index 2c514e9..b8ab235 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "test:scripts": "for t in scripts/tests/*.test.sh; do bash \"$t\" || exit 1; done", "check:analysis": "uv run --project packages/analysis --group dev mypy packages/analysis/src", "lint:analysis": "uv run --project packages/analysis --group dev ruff check packages/analysis", - "lint": "pnpm run lint:rust && pnpm run lint:analysis && pnpm run biome:check && pnpm run akg:verify && pnpm run cf:audit && pnpm run test:scripts && pnpm run lint:docs", + "lint": "pnpm run lint:rust && pnpm run lint:analysis && pnpm run biome:check && pnpm run akg:verify && pnpm run cf:audit && pnpm run test:scripts && pnpm run lint:docs && pnpm run agent:doctor:ci", "lint:docs": "node scripts/check-docs.mjs", "lint:rust": "cd packages/engine && env -u RUSTUP_TOOLCHAIN cargo clippy --all-targets --all-features -- -D warnings", "biome:check": "pnpm --filter @dicee/web biome:check && pnpm --filter @dicee/cloudflare-do lint && pnpm --filter @dicee/simulation lint", @@ -38,6 +38,8 @@ "security:public": "./scripts/public-safety-scan.sh", "cf:audit": "node scripts/cloudflare-config-audit.mjs", "cf:audit:strict": "node scripts/cloudflare-config-audit.mjs --strict", + "agent:doctor": "node scripts/agent-doctor.mjs --local", + "agent:doctor:ci": "node scripts/agent-doctor.mjs", "toolchain:versions": "node --version && pnpm --version && (cd packages/engine && env -u RUSTUP_TOOLCHAIN rustc --version && env -u RUSTUP_TOOLCHAIN cargo --version) && python3 --version && uv --version && bun --version", "web:sync": "pnpm --filter @dicee/web exec svelte-kit sync", "web:vitest": "pnpm --filter @dicee/web exec vitest", diff --git a/scripts/agent-doctor.mjs b/scripts/agent-doctor.mjs new file mode 100644 index 0000000..a66ff8e --- /dev/null +++ b/scripts/agent-doctor.mjs @@ -0,0 +1,1568 @@ +#!/usr/bin/env node +/** + * agent-doctor.mjs — dependency-free agent-configuration drift gate. + * + * WHY THIS EXISTS + * scripts/check-docs.mjs fixes its file set to `git ls-files` and knows exactly one + * settings file, so every ignored file is structurally invisible to it. That is how a + * personal, ignored Claude settings file came to enable every project MCP server and + * pre-allow mutating Supabase tools while `pnpm lint:docs` stayed green. check-docs also + * never compares two client files against each other, so one MCP server can drift into + * four different launchers without a finding. This gate closes both blind spots without + * making CI depend on local state. + * + * TWO CLASSES OF CHECK + * COMMITTED deterministic, reads only tracked files, runs in CI, fails hard (error). + * LOCAL opportunistic, included only with --local. Each local check runs only when + * the file or executable it needs actually exists; an absent one is a skipped + * note, never a failure. Local findings are warnings and never fail a default + * run, so `pnpm lint` can invoke the bare command safely while a developer + * running `pnpm agent:doctor` (which passes --local) still sees everything. + * `--strict` makes warnings fatal for anyone who wants that locally. + * + * COMMITTED RULES + * CONTRACT AGENTS.md exists and stays inside its line budget + * PARSE every committed agent-configuration file parses + * DENY .claude/settings.json denies the mutating Supabase MCP tools + * POLICY .claude/settings.json pins no model or effort level — that is personal policy + * MODEL no deprecated or pinned model id in any tracked client configuration file + * LAUNCHER every client launches the AKG MCP server through the pinned toolchain + * SERVERS every client declares exactly the MCP servers its role allows + * SECRET no `headers` key and no credential-shaped literal in an MCP-carrying file + * RETIRED no retired agent surface is tracked + * + * LOCAL RULES + * CLIENT report the installed build of each agent client found on PATH + * PERSONAL the ignored personal Claude settings file keeps the agreed shape + * IDENTITY Git identity routing covers this repository + * STRAY no retired agent surface sits untracked in the working tree + * + * WHAT THIS IS NOT + * Read-only and offline. It opens no socket, reads no credential, and imports nothing + * from node_modules. It never prints a secret, an account identifier, or the configured + * Git email; the IDENTITY rule reports only which configuration file supplied the value. + * It inspects configuration, which is never evidence of live state. + * + * DELIBERATE NON-DUPLICATION + * The `retired` table in scripts/check-docs.config.json already enforces retired + * *content* tokens — the memory MCP namespace, the remote-MCP bridge, the + * credential-forwarding wrappers, the retired secret store and the retired editor + * client — across every tracked file with a scanned extension. This gate therefore + * checks retired *paths*, which a content scan cannot see: a tracked directory whose + * files never happen to name it stays invisible to it. + * + * USAGE + * node scripts/agent-doctor.mjs # committed checks; exit 1 on any error + * node scripts/agent-doctor.mjs --local # also run the local checks (warnings) + * node scripts/agent-doctor.mjs --report # print every finding, always exit 0 + * node scripts/agent-doctor.mjs --strict # warnings are fatal too + * node scripts/agent-doctor.mjs --json # deterministic machine-readable output + * node scripts/agent-doctor.mjs --only LAUNCHER # run a subset of the rules + * node scripts/agent-doctor.mjs --root # audit another checkout (test fixtures) + * node scripts/agent-doctor.mjs --self-test # exercise the pure helpers + * + * Findings print as `path:line: RULE message`, sorted, with `[warn]` or `[info]` marking + * anything that is not an error. Exit 2 is a usage or environment error. + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { stripJsonc } from './cloudflare-config-audit.mjs'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_ROOT = resolve(SCRIPT_DIR, '..'); + +const COMMITTED_RULES = [ + 'CONTRACT', + 'PARSE', + 'DENY', + 'POLICY', + 'MODEL', + 'LAUNCHER', + 'SERVERS', + 'SECRET', + 'RETIRED', +]; +const LOCAL_RULES = ['CLIENT', 'PERSONAL', 'IDENTITY', 'STRAY']; +const RULES = [...COMMITTED_RULES, ...LOCAL_RULES]; + +/* ── Repository surface under inspection ─────────────────────────────────── */ + +/** + * `.claude` is spelled once and every path below is built from it. Written out in full, + * the personal settings path would be read by the SEE rule of scripts/check-docs.mjs as a + * reference that must resolve — and it never resolves, because that file is ignored by + * design. Composing it states the same path without asking a gate to believe an ignored + * file is tracked. + */ +const CLAUDE_DIR = '.claude'; +const SETTINGS = `${CLAUDE_DIR}/settings.json`; +const LOCAL_SETTINGS = `${CLAUDE_DIR}/settings.local.json`; + +const AGENTS = 'AGENTS.md'; +const DOCS_CONFIG = 'scripts/check-docs.config.json'; +const AKG_SERVER = 'packages/web/src/tools/akg/mcp/server.ts'; +const AGENTS_FALLBACK_BUDGET = 120; + +/** Pseudo-paths for findings that belong to the environment rather than a file. */ +const CLIENTS_SCOPE = '(clients)'; +const GIT_SCOPE = '(git)'; +const TREE_SCOPE = '(working tree)'; + +/** The two servers every client may enable without an explicit opt-in. */ +const ENABLED_MCP = ['akg', 'cloudflare-docs']; + +/** + * MCP-carrying client files. + * + * `.mcp.json` is the project file Claude Code reads, and Claude Code gates each server + * behind `enabledMcpjsonServers`, so it may also *declare* the two opt-in OAuth servers. + * No other client has that gate, so every other file declares only the enabled two. + * These four sentences are prose in docs/development/agent-clients.md; here they are one + * comparison that fails when a client drifts. + */ +const MCP_FILES = [ + { + file: '.mcp.json', + format: 'json', + key: 'mcpServers', + expect: [...ENABLED_MCP, 'cloudflare-api', 'supabase'], + }, + { file: '.cursor/mcp.json', format: 'jsonc', key: 'mcpServers', expect: ENABLED_MCP }, + { file: '.vscode/mcp.json', format: 'jsonc', key: 'servers', expect: ENABLED_MCP }, + { file: '.codex/config.toml', format: 'toml', key: 'mcp_servers', expect: ENABLED_MCP }, +]; + +/** Every committed file this gate parses, including the ones that carry no MCP server. */ +const PARSED_FILES = [{ file: SETTINGS, format: 'json' }, ...MCP_FILES]; + +/** + * The pinned launcher. `mise exec -- bun run …` runs the AKG server under the toolchain + * pinned in .mise.toml; a bare `bun` uses whatever bun is first on the developer's PATH, + * which is how this drifted apart across four files in the first place. + */ +const PINNED_COMMAND = 'mise'; +const PINNED_ARGS = ['exec', '--', 'bun']; + +/** Keys that make model or effort choice a repository decision instead of a personal one. */ +const PERSONAL_POLICY_KEYS = ['model', 'maxEffortLevel']; + +/** + * Model ids that must never be pinned in repository configuration: retired families, and + * models near retirement that new configuration must not target. Guidance about which + * model to use belongs in prose, not in a committed key that silently outlives the model. + */ +const DEPRECATED_MODEL_IDS = [ + { id: 'claude-3', re: /claude-3/i }, + { id: 'claude-4 family', re: /claude-(?:sonnet-|opus-|haiku-)?4/i }, + { id: 'sonnet-4', re: /(? value.replace(/\$\{[^}]*\}/g, ''); + +class DoctorError extends Error {} + +/* ── CLI ─────────────────────────────────────────────────────────────────── */ + +function parseArgs(argv) { + const options = { + local: false, + strict: false, + report: false, + json: false, + selfTest: false, + help: false, + root: DEFAULT_ROOT, + only: null, + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const [flag, inline] = arg.startsWith('--') && arg.includes('=') ? arg.split(/=(.*)/s) : [arg]; + if (inline === undefined && flag === '--local') options.local = true; + else if (inline === undefined && flag === '--strict') options.strict = true; + else if (inline === undefined && flag === '--report') options.report = true; + else if (inline === undefined && flag === '--json') options.json = true; + else if (inline === undefined && flag === '--self-test') options.selfTest = true; + else if (inline === undefined && (flag === '--help' || flag === '-h')) options.help = true; + else if (flag === '--root' || flag === '--only') { + const value = inline ?? argv[++i]; + if (value === undefined || value === '') throw new DoctorError(`${flag} needs a value`); + if (flag === '--root') { + options.root = resolve(process.cwd(), value); + } else { + options.only = value + .split(',') + .map((rule) => rule.trim().toUpperCase()) + .filter(Boolean); + const unknown = options.only.filter((rule) => !RULES.includes(rule)); + if (unknown.length > 0 || options.only.length === 0) { + throw new DoctorError(`--only accepts ${RULES.join(',')}; got "${value}"`); + } + } + } else { + throw new DoctorError(`unknown argument "${arg}"`); + } + } + // `--only PERSONAL` would otherwise select a rule and then skip it. + if (options.only?.some((rule) => LOCAL_RULES.includes(rule))) options.local = true; + return options; +} + +/* ── Small helpers ───────────────────────────────────────────────────────── */ + +const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); + +/** Minimal glob matcher: `**` spans directories, `*` and `?` stay within one segment. */ +function globToRegExp(glob) { + let source = ''; + for (let i = 0; i < glob.length; i++) { + const char = glob[i]; + if (char === '*' && glob[i + 1] === '*') { + i++; + if (glob[i + 1] === '/') { + i++; + source += '(?:.*/)?'; + } else { + source += '.*'; + } + } else if (char === '*') { + source += '[^/]*'; + } else if (char === '?') { + source += '[^/]'; + } else { + source += char.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + } + } + return new RegExp(`^${source}$`); +} + +const countLines = (text) => text.split('\n').length - 1; + +/** 1-based line of the first occurrence of `needle`, or 1 when it is absent. */ +function lineContaining(text, needle) { + const index = text.indexOf(needle); + return index === -1 ? 1 : countLines(text.slice(0, index)) + 1; +} + +/** + * Replace the home directory with `~` anywhere in a reported string. Local findings quote + * real configuration, and a home directory carries a user name that nothing here needs. + */ +function tilde(text) { + const home = homedir(); + if (!home) return text; + return text.split(home).join('~'); +} + +/* ── Minimal TOML reader ─────────────────────────────────────────────────── */ + +/** + * Enough TOML to read `[mcp_servers.*]` tables and their values: table headings, key/value + * pairs, basic and literal strings, arrays (including multi-line ones) and inline tables. + * + * A TOML dependency is not worth taking for this. What matters is that a file this reader + * cannot understand is reported as unparsed rather than silently treated as empty — an + * empty table set would make SERVERS and LAUNCHER pass on a broken file. + * + * Not supported, and rejected rather than mis-read: multi-line (triple-quoted) strings and + * arrays of tables. Neither appears in this repository's Codex configuration. + */ +function parseMiniToml(text) { + const lines = text.replace(/\r\n?/g, '\n').split('\n'); + /** @type {Map>} */ + const tables = new Map(); + const errors = []; + let current = ''; + tables.set('', new Map()); + + for (let i = 0; i < lines.length; i++) { + const stripped = stripTomlComment(lines[i]); + if (!stripped.ok) { + errors.push({ line: i + 1, message: 'unterminated string' }); + continue; + } + let body = stripped.text.trim(); + if (body === '') continue; + + if (body.startsWith('[[')) { + errors.push({ line: i + 1, message: 'arrays of tables are not supported by this reader' }); + continue; + } + if (body.startsWith('[')) { + if (!body.endsWith(']')) { + errors.push({ line: i + 1, message: `malformed table heading "${body}"` }); + continue; + } + current = body.slice(1, -1).trim(); + if (current === '') { + errors.push({ line: i + 1, message: 'empty table heading' }); + continue; + } + if (tables.has(current)) { + errors.push({ line: i + 1, message: `table [${current}] is declared twice` }); + continue; + } + tables.set(current, new Map()); + continue; + } + + const equals = indexOfTopLevel(body, '='); + if (equals === -1) { + errors.push({ line: i + 1, message: `not a table heading or key/value pair: "${body}"` }); + continue; + } + const key = unquote(body.slice(0, equals).trim()); + let raw = body.slice(equals + 1).trim(); + // A value whose brackets or braces are still open continues on the next lines. + let consumed = i; + while (unbalanced(raw) && consumed + 1 < lines.length) { + consumed++; + const more = stripTomlComment(lines[consumed]); + if (!more.ok) { + errors.push({ line: consumed + 1, message: 'unterminated string' }); + break; + } + raw = `${raw} ${more.text.trim()}`; + } + i = consumed; + if (key === '' || unbalanced(raw)) { + errors.push({ line: i + 1, message: `malformed key/value pair: "${body}"` }); + continue; + } + const parsed = parseTomlValue(raw); + if (!parsed.ok) { + errors.push({ line: i + 1, message: `unreadable value for "${key}": ${parsed.reason}` }); + continue; + } + tables.get(current).set(key, { value: parsed.value, line: i + 1 }); + } + + return { tables, errors }; +} + +/** Drop a `#` comment, keeping `#` inside a string. `ok` is false for an unterminated string. */ +function stripTomlComment(line) { + let out = ''; + let i = 0; + while (i < line.length) { + const char = line[i]; + if (char === '#') return { text: out, ok: true }; + if (char === '"' || char === "'") { + let closed = false; + out += char; + i++; + while (i < line.length) { + const next = line[i]; + if (char === '"' && next === '\\' && i + 1 < line.length) { + out += line.slice(i, i + 2); + i += 2; + continue; + } + out += next; + i++; + if (next === char) { + closed = true; + break; + } + } + if (!closed) return { text: out, ok: false }; + continue; + } + out += char; + i++; + } + return { text: out, ok: true }; +} + +/** Index of the first `needle` outside any string, bracket or brace, or -1. */ +function indexOfTopLevel(text, needle) { + let depth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char === '"' || char === "'") { + i = skipString(text, i); + continue; + } + if (char === '[' || char === '{') depth++; + else if (char === ']' || char === '}') depth--; + else if (depth === 0 && char === needle) return i; + } + return -1; +} + +/** Index of the closing quote of the string starting at `start`, or the end of the text. */ +function skipString(text, start) { + const quote = text[start]; + for (let i = start + 1; i < text.length; i++) { + if (quote === '"' && text[i] === '\\') { + i++; + continue; + } + if (text[i] === quote) return i; + } + return text.length; +} + +/** True when a value still has an open bracket or brace. */ +function unbalanced(text) { + let depth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char === '"' || char === "'") { + i = skipString(text, i); + continue; + } + if (char === '[' || char === '{') depth++; + else if (char === ']' || char === '}') depth--; + } + return depth > 0; +} + +/** Split on top-level commas, ignoring commas inside strings, arrays and inline tables. */ +function splitTopLevel(text) { + const parts = []; + let depth = 0; + let start = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char === '"' || char === "'") { + i = skipString(text, i); + continue; + } + if (char === '[' || char === '{') depth++; + else if (char === ']' || char === '}') depth--; + else if (char === ',' && depth === 0) { + parts.push(text.slice(start, i)); + start = i + 1; + } + } + parts.push(text.slice(start)); + return parts.map((part) => part.trim()).filter((part) => part !== ''); +} + +/** Remove surrounding quotes from a bare or quoted key. */ +function unquote(token) { + const match = token.match(/^(["'])(.*)\1$/s); + return match ? match[2] : token; +} + +/** Parse a TOML value into a JavaScript value. Unknown scalars stay as their raw text. */ +function parseTomlValue(raw) { + const text = raw.trim(); + if (text === '') return { ok: false, reason: 'empty value' }; + if (text.startsWith('"') || text.startsWith("'")) { + const end = skipString(text, 0); + if (end >= text.length) return { ok: false, reason: 'unterminated string' }; + if (text.slice(end + 1).trim() !== '') return { ok: false, reason: 'trailing text after string' }; + const body = text.slice(1, end); + return { ok: true, value: text[0] === "'" ? body : unescapeBasic(body) }; + } + if (text.startsWith('[')) { + if (!text.endsWith(']')) return { ok: false, reason: 'unterminated array' }; + const items = []; + for (const part of splitTopLevel(text.slice(1, -1))) { + const item = parseTomlValue(part); + if (!item.ok) return item; + items.push(item.value); + } + return { ok: true, value: items }; + } + if (text.startsWith('{')) { + if (!text.endsWith('}')) return { ok: false, reason: 'unterminated inline table' }; + const table = {}; + for (const part of splitTopLevel(text.slice(1, -1))) { + const equals = indexOfTopLevel(part, '='); + if (equals === -1) return { ok: false, reason: `inline table entry "${part}" has no value` }; + const item = parseTomlValue(part.slice(equals + 1)); + if (!item.ok) return item; + table[unquote(part.slice(0, equals).trim())] = item.value; + } + return { ok: true, value: table }; + } + if (text === 'true') return { ok: true, value: true }; + if (text === 'false') return { ok: true, value: false }; + if (/^[+-]?(?:\d[\d_]*)(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?$/.test(text)) { + return { ok: true, value: Number(text.replace(/_/g, '')) }; + } + return { ok: true, value: text }; +} + +/** The escapes TOML basic strings actually use in this repository's configuration. */ +function unescapeBasic(body) { + return body.replace(/\\(["\\bfnrt])/g, (_, char) => { + const map = { b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' }; + return map[char] ?? char; + }); +} + +/** Fold dotted table names into one nested object, so TOML and JSON walk identically. */ +function tomlToObject(tables) { + const root = {}; + for (const [name, entries] of tables) { + let node = root; + if (name !== '') { + for (const segment of splitDottedKey(name)) { + if (!isObject(node[segment])) node[segment] = {}; + node = node[segment]; + } + } + for (const [key, entry] of entries) node[key] = entry.value; + } + return root; +} + +/** Split `a.b."c.d"` into its segments. */ +function splitDottedKey(name) { + const segments = []; + let start = 0; + for (let i = 0; i < name.length; i++) { + if (name[i] === '"' || name[i] === "'") { + i = skipString(name, i); + continue; + } + if (name[i] === '.') { + segments.push(unquote(name.slice(start, i).trim())); + start = i + 1; + } + } + segments.push(unquote(name.slice(start).trim())); + return segments.filter((segment) => segment !== ''); +} + +/* ── Repository model ────────────────────────────────────────────────────── */ + +function loadRepository(root) { + if (!existsSync(root) || !statSync(root).isDirectory()) { + throw new DoctorError(`--root ${root} is not a directory`); + } + let output; + try { + output = execFileSync('git', ['ls-files', '-z'], { + cwd: root, + encoding: 'utf8', + maxBuffer: 1 << 28, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + throw new DoctorError(`git ls-files failed (${String(error.stderr || error.message).trim()})`); + } + const tracked = new Set(output.split('\0').filter(Boolean)); + return { root, tracked, texts: new Map(), documents: new Map() }; +} + +function readText(repo, path) { + let text = repo.texts.get(path); + if (text === undefined) { + text = readFileSync(join(repo.root, path), 'utf8').replace(/\r\n?/g, '\n'); + repo.texts.set(path, text); + } + return text; +} + +/** + * Parse one committed configuration file once, in the format its client actually accepts. + * Returns `{ ok, data, text }` or `{ ok: false, reason, line }`. + */ +function loadDocument(repo, entry) { + const cached = repo.documents.get(entry.file); + if (cached) return cached; + let result; + if (!repo.tracked.has(entry.file)) { + result = { ok: false, reason: 'file is not tracked', line: 1 }; + } else if (!existsSync(join(repo.root, entry.file))) { + result = { ok: false, reason: 'tracked file is missing from the working tree', line: 1 }; + } else { + const text = readText(repo, entry.file); + if (entry.format === 'toml') { + const { tables, errors } = parseMiniToml(text); + result = errors.length + ? { ok: false, reason: errors[0].message, line: errors[0].line, text } + : { ok: true, data: tomlToObject(tables), text }; + } else { + try { + // Claude Code reads .mcp.json and its settings as strict JSON; VS Code and + // Cursor accept comments, so their files are read as JSONC. + const source = entry.format === 'jsonc' ? stripJsonc(text) : text; + const data = JSON.parse(source); + result = isObject(data) + ? { ok: true, data, text } + : { ok: false, reason: 'top-level value is not an object', line: 1, text }; + } catch (error) { + result = { ok: false, reason: error.message, line: 1, text }; + } + } + } + repo.documents.set(entry.file, result); + return result; +} + +/** The MCP servers a parsed client document declares, normalised across formats. */ +function declaredServers(entry, data) { + const container = data[entry.key]; + if (!isObject(container)) return null; + const servers = new Map(); + for (const [name, definition] of Object.entries(container)) { + if (isObject(definition)) servers.set(name, definition); + } + return servers; +} + +/* ── Committed rules ─────────────────────────────────────────────────────── */ + +function ruleContract({ repo, add }) { + if (!repo.tracked.has(AGENTS)) { + add(AGENTS, 1, 'CONTRACT', 'error', 'the repository contract is missing'); + return; + } + // The budget lives in the docs gate's config so there is one number, not two. + let budget = AGENTS_FALLBACK_BUDGET; + let source = 'built-in default'; + if (repo.tracked.has(DOCS_CONFIG)) { + try { + const configured = JSON.parse(readText(repo, DOCS_CONFIG))?.budgets?.[AGENTS]?.maxLines; + if (Number.isInteger(configured) && configured > 0) { + budget = configured; + source = DOCS_CONFIG; + } + } catch { + // A malformed docs config is that gate's finding, not this one's. + } + } + const lines = countLines(readText(repo, AGENTS)); + if (lines > budget) { + add(AGENTS, budget + 1, 'CONTRACT', 'error', `${lines} lines exceeds the budget of ${budget}`); + return; + } + add( + AGENTS, + 1, + 'CONTRACT', + 'info', + `${lines} of ${budget} lines (${budget - lines} line(s) of headroom, budget from ${source})`, + ); +} + +function ruleParse({ repo, add }) { + for (const entry of PARSED_FILES) { + const document = loadDocument(repo, entry); + if (!document.ok) { + add(entry.file, document.line ?? 1, 'PARSE', 'error', `does not parse: ${document.reason}`); + } + } +} + +function ruleDeny({ repo, add }) { + const document = loadDocument(repo, { file: SETTINGS, format: 'json' }); + if (!document.ok) return; // PARSE owns this failure + const deny = document.data.permissions?.deny; + const rules = new Set(Array.isArray(deny) ? deny : []); + for (const tool of SUPABASE_MUTATION_TOOLS) { + if (!rules.has(tool)) { + add( + SETTINGS, + lineContaining(document.text, '"deny"'), + 'DENY', + 'error', + `permissions.deny must list "${tool}"; an MCP session is not authority for a remote database write`, + ); + } + } +} + +function rulePolicy({ repo, add }) { + const document = loadDocument(repo, { file: SETTINGS, format: 'json' }); + if (!document.ok) return; + for (const key of PERSONAL_POLICY_KEYS) { + if (key in document.data) { + add( + SETTINGS, + lineContaining(document.text, `"${key}"`), + 'POLICY', + 'error', + `"${key}" is personal policy, not repository policy; keep model and effort choices in the user configuration`, + ); + } + } +} + +function ruleModel({ repo, add }) { + const matchers = MODEL_SCAN_GLOBS.map(globToRegExp); + const files = [...repo.tracked] + .filter((path) => matchers.some((matcher) => matcher.test(path))) + .sort(); + for (const file of files) { + if (!existsSync(join(repo.root, file))) continue; + readText(repo, file) + .split('\n') + .forEach((line, index) => { + for (const model of DEPRECATED_MODEL_IDS) { + const match = line.match(model.re); + if (match) { + add( + file, + index + 1, + 'MODEL', + 'error', + `pinned or deprecated model id "${match[0]}" (${model.id}); model choice is guidance, not committed configuration`, + ); + } + } + }); + } +} + +function ruleLauncher({ repo, add }) { + for (const entry of MCP_FILES) { + const document = loadDocument(repo, entry); + if (!document.ok) continue; + const servers = declaredServers(entry, document.data); + const akg = servers?.get('akg'); + if (!akg) continue; // SERVERS owns a missing server + const line = lineContaining(document.text, 'akg'); + const command = akg.command; + const args = Array.isArray(akg.args) ? akg.args.map(String) : []; + if (command !== PINNED_COMMAND) { + add( + entry.file, + line, + 'LAUNCHER', + 'error', + `akg command is ${JSON.stringify(command ?? null)}; every client must launch it with "${PINNED_COMMAND}" so it runs under the pinned toolchain`, + ); + continue; + } + const prefix = args.slice(0, PINNED_ARGS.length); + if (prefix.join('\u0000') !== PINNED_ARGS.join('\u0000')) { + add( + entry.file, + line, + 'LAUNCHER', + 'error', + `akg args begin ${JSON.stringify(prefix)}; they must begin ${JSON.stringify(PINNED_ARGS)} so the runtime comes from .mise.toml rather than PATH`, + ); + } + } +} + +function ruleServers({ repo, add }) { + for (const entry of MCP_FILES) { + const document = loadDocument(repo, entry); + if (!document.ok) continue; + const servers = declaredServers(entry, document.data); + if (servers === null) { + add( + entry.file, + 1, + 'SERVERS', + 'error', + `no "${entry.key}" object; this file declares no MCP server at all`, + ); + continue; + } + const declared = [...servers.keys()].sort(); + const expected = [...entry.expect].sort(); + if (declared.join(',') !== expected.join(',')) { + add( + entry.file, + lineContaining(document.text, entry.key), + 'SERVERS', + 'error', + `declares ${JSON.stringify(declared)}; expected ${JSON.stringify(expected)}`, + ); + } + } +} + +/** + * The 2026-05-08 MCP bearer-argv rule, mechanised: no MCP-carrying file may hold a + * credential, and none may set request headers. A header block is how a token gets + * forwarded without ever looking like one, so the key itself is the finding. + * + * Every string leaf is scanned, not only url / args / env, because a credential pasted + * into some other key is the same incident with a different field name. + */ +function ruleSecret({ repo, add }) { + for (const entry of MCP_FILES) { + const document = loadDocument(repo, entry); + if (!document.ok) continue; + for (const hit of credentialHits(document.data)) { + add( + entry.file, + lineContaining(document.text, hit.anchor), + 'SECRET', + 'error', + hit.message, + ); + } + } +} + +/** Walk a parsed configuration document and describe everything credential-shaped in it. */ +function credentialHits(data) { + const hits = []; + const walk = (node, path) => { + if (Array.isArray(node)) { + node.forEach((item, index) => walk(item, `${path}[${index}]`)); + return; + } + if (isObject(node)) { + for (const [key, value] of Object.entries(node)) { + const here = path === '' ? key : `${path}.${key}`; + if (key.toLowerCase() === 'headers') { + hits.push({ + anchor: key, + message: `"${here}" sets request headers; an MCP server is configured without credentials, and a header block is how a token reaches one`, + }); + } else if (CREDENTIAL_KEY.test(key) && typeof value === 'string') { + if (withoutPlaceholders(value).trim() !== '') { + hits.push({ + anchor: key, + message: `"${here}" is a credential-shaped key with a literal value`, + }); + } + } + walk(value, here); + } + return; + } + if (typeof node !== 'string' || node === '') return; + const value = withoutPlaceholders(node); + for (const shape of CREDENTIAL_VALUES) { + if (shape.re.test(value)) { + hits.push({ anchor: path.split('.').pop(), message: `"${path}" contains a ${shape.id}` }); + return; + } + } + for (const param of credentialQueryParams(value)) { + hits.push({ + anchor: path.split('.').pop(), + message: `"${path}" carries "${param}" in a URL; a credential must never travel in a URL`, + }); + } + }; + walk(data, ''); + return hits; +} + +/** Query parameter names in `value` that would carry a credential, with a real value. */ +function credentialQueryParams(value) { + const start = value.indexOf('?'); + if (start === -1) return []; + const query = value.slice(start + 1); + const names = []; + for (const pair of query.split(/[&;]/)) { + const equals = pair.indexOf('='); + if (equals <= 0) continue; + const name = pair.slice(0, equals); + if (CREDENTIAL_PARAM.test(name) && pair.slice(equals + 1).trim() !== '') names.push(name); + } + return names; +} + +function ruleRetired({ repo, add }) { + for (const surface of RETIRED_SURFACES) { + const matcher = globToRegExp(surface.glob); + for (const path of [...repo.tracked].filter((file) => matcher.test(file)).sort()) { + add(path, 1, 'RETIRED', 'error', `tracked retired surface (${surface.reason})`); + } + } +} + +/* ── Local rules ─────────────────────────────────────────────────────────── */ + +function ruleClient({ add }) { + for (const client of CLIENT_BINARIES) { + let output; + try { + output = execFileSync(client.bin, ['--version'], { + encoding: 'utf8', + timeout: 15_000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + add(CLIENTS_SCOPE, 1, 'CLIENT', 'info', `${client.label}: ${client.bin} is not on PATH`); + continue; + } + const version = output.split('\n').find((line) => line.trim() !== '')?.trim() ?? '(no output)'; + add( + CLIENTS_SCOPE, + 1, + 'CLIENT', + 'info', + `${client.label} installed build: ${version} — the build on this machine, not a claim about the vendor's public stable channel`, + ); + } +} + +/** + * The ignored personal settings file. Nothing in CI reads it, and the docs gate cannot see + * it at all, so this is the only place it is checked; every finding is a warning. + */ +function rulePersonal({ repo, add }) { + const absolute = join(repo.root, LOCAL_SETTINGS); + if (!existsSync(absolute)) { + add(LOCAL_SETTINGS, 1, 'PERSONAL', 'info', 'not present; personal-settings checks skipped'); + return; + } + const text = readFileSync(absolute, 'utf8').replace(/\r\n?/g, '\n'); + let settings; + try { + settings = JSON.parse(text); + } catch (error) { + add(LOCAL_SETTINGS, 1, 'PERSONAL', 'warn', `does not parse: ${error.message}`); + return; + } + if (!isObject(settings)) { + add(LOCAL_SETTINGS, 1, 'PERSONAL', 'warn', 'top-level value is not an object'); + return; + } + + if (settings.enableAllProjectMcpServers) { + add( + LOCAL_SETTINGS, + lineContaining(text, '"enableAllProjectMcpServers"'), + 'PERSONAL', + 'warn', + `enableAllProjectMcpServers turns on every server in .mcp.json, including the opt-in OAuth ones; enable servers by name instead (${ENABLED_MCP.join(', ')})`, + ); + } + + const enabled = Array.isArray(settings.enabledMcpjsonServers) ? settings.enabledMcpjsonServers : []; + for (const name of enabled) { + if (!ENABLED_MCP.includes(name)) { + add( + LOCAL_SETTINGS, + lineContaining(text, JSON.stringify(name)), + 'PERSONAL', + 'warn', + `enables MCP server "${name}"; only ${ENABLED_MCP.join(' and ')} are enabled by default, the rest are opt-in per session`, + ); + } + } + + const permissions = isObject(settings.permissions) ? settings.permissions : {}; + const preapproved = [ + ...(Array.isArray(permissions.allow) ? permissions.allow : []), + ...(Array.isArray(permissions.ask) ? permissions.ask : []), + ]; + const everyRule = [...preapproved, ...(Array.isArray(permissions.deny) ? permissions.deny : [])]; + const akgTools = akgToolNames(repo); + + for (const rule of preapproved) { + if (typeof rule !== 'string') continue; + const line = lineContaining(text, JSON.stringify(rule)); + const supabase = rule.match(/^mcp__supabase__([A-Za-z0-9_]+)$/); + if (supabase && !SUPABASE_READ_ONLY_TOOLS.has(supabase[1])) { + add( + LOCAL_SETTINGS, + line, + 'PERSONAL', + 'warn', + `pre-approves "${rule}"; the Supabase MCP server is read-only by configuration, and anything outside its read-only tool set must stay a per-call decision`, + ); + } + if (akgTools && rule.startsWith('mcp__akg__')) { + const tool = rule.slice('mcp__akg__'.length); + if (!akgTools.has(tool)) { + add( + LOCAL_SETTINGS, + line, + 'PERSONAL', + 'warn', + `names "${rule}", which the AKG server does not expose; it exposes ${[...akgTools].sort().join(', ')}`, + ); + } + } + for (const problem of malformedBashRule(repo, rule)) { + add(LOCAL_SETTINGS, line, 'PERSONAL', 'warn', problem); + } + } + + for (const rule of everyRule) { + if (typeof rule !== 'string') continue; + for (const namespace of RETIRED_MCP_NAMESPACES) { + if (rule.startsWith(namespace)) { + add( + LOCAL_SETTINGS, + lineContaining(text, JSON.stringify(rule)), + 'PERSONAL', + 'warn', + `names "${rule}", a retired MCP namespace this repository no longer configures`, + ); + } + } + } +} + +/** Tool names the AKG MCP server registers, or null when the server source is unavailable. */ +function akgToolNames(repo) { + const absolute = join(repo.root, AKG_SERVER); + if (!existsSync(absolute)) return null; + const names = new Set(); + for (const match of readFileSync(absolute, 'utf8').matchAll( + /registerTool\(\s*['"]([A-Za-z0-9_]+)['"]/g, + )) { + names.add(match[1]); + } + return names.size > 0 ? names : null; +} + +/** An absolute path inside a permission rule, wherever it sits in the command line. */ +const ABSOLUTE_PATH_TOKEN = /(?:^|[\s:="'])(\/(?!\/)[^\s:="'*]+)/g; + +/** + * Why a `Bash(...)` permission rule names a file instead of a command. + * + * Two shapes are reported. An absolute path outside the repository is machine-specific: + * it silently matches nothing on any other checkout, and it is the shape a path-completion + * mistake produces. Such a path is looked for anywhere in the rule, not only in the first + * word, because the ones that survive review hide behind an environment prefix such as + * `PATH="…" tool` or `VAR=value /opt/…/tool`. A path that instead resolves inside the + * repository to a file with no execute bit is not a command at all, so the rule can never + * match anything either. + */ +function malformedBashRule(repo, rule) { + const match = rule.match(/^Bash\((.*)\)$/s); + if (!match) return []; + const body = match[1].trim(); + if (body === '') return []; + const problems = []; + + const foreign = [...body.matchAll(ABSOLUTE_PATH_TOKEN)] + .map((found) => found[1]) + .filter((path) => path !== repo.root && !path.startsWith(`${repo.root}/`)); + if (foreign.length > 0) { + problems.push( + `rule ${JSON.stringify(rule)} names the machine-specific absolute path(s) ${foreign.map((path) => JSON.stringify(path)).join(', ')}; a permission rule names a command on PATH or a repository-relative script`, + ); + } + + // The command word, after any leading `NAME=value` environment assignments. + const words = body.split(/\s+/).filter((word) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)); + const first = (words[0] ?? '').replace(/:\*+$/, '').replace(/\*+$/, ''); + if (first !== '' && !first.includes('*') && !first.startsWith('/')) { + if (first.includes('/') || first.includes('.')) { + const absolute = join(repo.root, first); + const stat = existsSync(absolute) ? statSync(absolute) : null; + if (stat?.isFile() && (stat.mode & 0o111) === 0) { + problems.push( + `rule ${JSON.stringify(rule)} names "${first}", a file with no execute bit; it is a path, not a command, so the rule can never match`, + ); + } + } + } + return problems; +} + +/** + * Git identity routing. Reports only whether a conditional include covers this repository + * and which configuration file supplied the value — never the address itself. + */ +function ruleIdentity({ repo, add }) { + const git = (args) => { + try { + return execFileSync('git', args, { + cwd: repo.root, + encoding: 'utf8', + timeout: 15_000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return null; + } + }; + + const value = git(['config', '--get', 'user.email']); + if (value === null || value.trim() === '') { + add(GIT_SCOPE, 1, 'IDENTITY', 'warn', 'no user.email is configured for this repository'); + return; + } + + const shown = git(['config', '--show-origin', '--get', 'user.email']); + // `file:\t` — only the path is ever read out of this. + const origin = shown?.split('\n')[0]?.split('\t')[0]?.replace(/^file:/, '') ?? null; + const originLabel = origin ? tilde(origin) : '(unknown)'; + + const routes = git(['config', '--show-origin', '--get-regexp', '^includeif\\.']) ?? ''; + const matched = []; + for (const line of routes.split('\n')) { + const key = line.split('\t')[1]?.split(' ')[0]; + if (!key) continue; + const condition = key.replace(/^includeif\./i, '').replace(/\.path$/i, ''); + if (includeIfMatches(condition, repo.root)) matched.push(condition); + } + + if (matched.length > 0) { + add( + GIT_SCOPE, + 1, + 'IDENTITY', + 'info', + `identity routing covers this repository (${matched.length} matching includeIf condition(s)); user.email comes from ${originLabel}`, + ); + return; + } + add( + GIT_SCOPE, + 1, + 'IDENTITY', + 'warn', + `no includeIf condition matches this repository, so user.email falls back to ${originLabel}; add a conditional include for this workspace instead of relying on the global default`, + ); +} + +/** + * Resolve the symlink-free form of the literal directory prefix of a glob pattern. + * + * `git rev-parse` reports a repository through its real path, while a condition in + * `~/.gitconfig` is usually written through whatever path the operator types. On macOS + * `/var` is a symlink to `/private/var`, so the two spellings of one directory would not + * compare equal. Only the part before the first glob character can be resolved; the rest + * is left alone, and an unresolvable prefix keeps the original spelling. + */ +function resolveLiteralPrefix(pattern) { + const glob = pattern.search(/[*?[]/); + const literal = glob === -1 ? pattern : pattern.slice(0, glob); + const rest = glob === -1 ? '' : pattern.slice(glob); + const trailing = literal.endsWith('/') ? '/' : ''; + const probe = trailing ? literal.slice(0, -1) : literal; + if (!probe) return pattern; + try { + return realpathSync(probe) + trailing + rest; + } catch { + return pattern; + } +} + +/** + * Whether a `gitdir:` includeIf condition covers `root`. + * + * Follows gitconfig(5) closely enough to answer the question honestly: `~/` expands to the + * home directory, a pattern ending in `/` matches everything beneath it, a pattern with no + * slash matches at any depth, `/i` makes the comparison case-insensitive, and `*`/`**` + * keep their glob meaning. Conditions this does not model (`onbranch:`, `hasconfig:`) never + * select an identity by path and are reported as non-matching. + */ +function includeIfMatches(condition, root) { + const lower = condition.toLowerCase(); + const insensitive = lower.startsWith('gitdir/i:'); + if (!insensitive && !lower.startsWith('gitdir:')) return false; + let pattern = condition.slice(condition.indexOf(':') + 1); + if (pattern.startsWith('~/')) pattern = join(homedir(), pattern.slice(2)); + pattern = resolveLiteralPrefix(pattern); + if (pattern.endsWith('/')) pattern += '**'; + if (!pattern.includes('/')) pattern = `**/${pattern}`; + const matcher = new RegExp(globToRegExp(pattern).source, insensitive ? 'i' : ''); + // A condition and a repository path can name the same directory through different + // symlinks — /var vs /private/var on macOS is the common case — so both forms are tried. + const roots = new Set([root]); + try { + roots.add(realpathSync(root)); + } catch { + // An unreadable path simply has no second form to compare. + } + for (const candidate of roots) { + if (matcher.test(candidate)) return true; + if (matcher.test(`${candidate}/`)) return true; + if (matcher.test(join(candidate, '.git'))) return true; + } + return false; +} + +function ruleStray({ repo, add }) { + for (const surface of RETIRED_SURFACES) { + for (const path of probePaths(repo, surface)) { + if (repo.tracked.has(path)) continue; // RETIRED already owns a tracked path + add( + TREE_SCOPE, + 1, + 'STRAY', + 'warn', + `"${path}" is present but untracked (${surface.reason}); it is ignored, so nothing else reports it — delete it rather than carrying it`, + ); + } + } +} + +/** Working-tree paths a retired surface's probe finds, if it has one. */ +function probePaths(repo, surface) { + const probe = surface.probe; + if (!probe) return []; + if (probe.file) return existsSync(join(repo.root, probe.file)) ? [probe.file] : []; + if (probe.entry) { + let entries; + try { + entries = readdirSync(join(repo.root, probe.dir)); + } catch { + return []; + } + return entries.filter((name) => probe.entry.test(name)).map((name) => `${probe.dir}/${name}`); + } + return existsSync(join(repo.root, probe.dir)) ? [`${probe.dir}/`] : []; +} + +const RULE_IMPLEMENTATIONS = { + CONTRACT: ruleContract, + PARSE: ruleParse, + DENY: ruleDeny, + POLICY: rulePolicy, + MODEL: ruleModel, + LAUNCHER: ruleLauncher, + SERVERS: ruleServers, + SECRET: ruleSecret, + RETIRED: ruleRetired, + CLIENT: ruleClient, + PERSONAL: rulePersonal, + IDENTITY: ruleIdentity, + STRAY: ruleStray, +}; + +/* ── Self-test ───────────────────────────────────────────────────────────── */ + +function selfTest() { + let total = 0; + let failed = 0; + const expect = (name, fn) => { + total++; + let ok = false; + let detail = ''; + try { + ok = fn() === true; + } catch (error) { + detail = ` (${error.message})`; + } + if (!ok) failed++; + console.log(` ${ok ? 'pass' : 'FAIL'} ${name}${detail}`); + }; + + console.log('\nagent-doctor self-test\n'); + + expect('TOML reader finds a dotted table and its string value', () => { + const { tables, errors } = parseMiniToml('[mcp_servers.akg]\ncommand = "mise"\n'); + return errors.length === 0 && tables.get('mcp_servers.akg').get('command').value === 'mise'; + }); + expect('TOML reader reads an array of strings', () => { + const { tables } = parseMiniToml('[a]\nargs = ["exec", "--", "bun"]\n'); + return tables.get('a').get('args').value.join(',') === 'exec,--,bun'; + }); + expect('TOML reader reads a multi-line array', () => { + const { tables, errors } = parseMiniToml('[a]\nargs = [\n "exec",\n "--",\n]\n'); + return errors.length === 0 && tables.get('a').get('args').value.length === 2; + }); + expect('TOML reader reads an inline table', () => { + const { tables } = parseMiniToml('[a]\nenv = { X = "1", Y = "2" }\n'); + return tables.get('a').get('env').value.Y === '2'; + }); + expect('TOML reader keeps a # inside a string', () => { + const { tables } = parseMiniToml('[a]\nurl = "https://x/y#frag" # note\n'); + return tables.get('a').get('url').value === 'https://x/y#frag'; + }); + expect('TOML reader reports an unterminated string instead of dropping the line', () => { + return parseMiniToml('[a]\nurl = "https://x\n').errors.length === 1; + }); + expect('TOML reader reports a line that is neither a heading nor a pair', () => { + return parseMiniToml('[a]\nnonsense\n').errors.length === 1; + }); + expect('TOML reader reports a duplicated table', () => { + return parseMiniToml('[a]\nx = 1\n[a]\ny = 2\n').errors.length === 1; + }); + expect('TOML tables fold into one nested object', () => { + const object = tomlToObject(parseMiniToml('[mcp_servers.akg]\ncommand = "mise"\n').tables); + return object.mcp_servers.akg.command === 'mise'; + }); + + expect('a headers key is a finding wherever it appears', () => { + const hits = credentialHits({ mcpServers: { x: { headers: { a: 'b' } } } }); + return hits.length === 1 && hits[0].message.includes('request headers'); + }); + expect('a bearer literal in an env value is a finding', () => { + return credentialHits({ env: { AUTH: 'Bearer abc123' } }).length >= 1; + }); + expect('a credential-shaped key with a literal value is a finding', () => { + return credentialHits({ servers: { x: { api_key: 'literal-value' } } }).length === 1; + }); + expect('a ${VAR} placeholder is not a literal', () => { + return credentialHits({ servers: { x: { token: '${MY_TOKEN}' } } }).length === 0; + }); + expect('a credential query parameter in a URL is a finding', () => { + const hits = credentialHits({ url: ['https://example.com/mcp', 'secret=abc'].join('?') }); + return hits.length === 1 && hits[0].message.includes('never travel in a URL'); + }); + expect('the real project_ref URL shape is not a finding', () => { + const url = 'https://mcp.example.com/mcp?project_ref=${REF}&read_only=true&features=docs'; + return credentialHits({ url }).length === 0; + }); + + expect('globToRegExp keeps * inside one segment', () => { + return globToRegExp('.codex/*.toml').test('.codex/config.toml') === true; + }); + expect('globToRegExp spans directories with **', () => { + const matcher = globToRegExp('.github/instructions/**'); + return matcher.test('.github/instructions/deep/rule.md') && !matcher.test('.github/other.md'); + }); + + expect('an includeIf gitdir prefix matches a repository beneath it', () => { + return includeIfMatches('gitdir:/tmp/example/', '/tmp/example/repo') === true; + }); + expect('an includeIf gitdir prefix does not match a sibling', () => { + return includeIfMatches('gitdir:/tmp/example/', '/tmp/other/repo') === false; + }); + expect('an onbranch condition never selects an identity by path', () => { + return includeIfMatches('onbranch:main', '/tmp/example/repo') === false; + }); + + const fixtureRepo = { root: '/tmp/example/repo' }; + expect('a machine-specific absolute path in a Bash rule is malformed', () => { + return malformedBashRule(fixtureRepo, 'Bash(/opt/example/bin/tool:*)').length === 1; + }); + expect('an absolute path hidden behind an environment prefix is still found', () => { + const rule = 'Bash(VAR=stable /opt/example/bin/tool build:*)'; + return malformedBashRule(fixtureRepo, rule).length === 1; + }); + expect('an absolute path inside a quoted assignment is still found', () => { + const rule = 'Bash(PATH="/opt/example/bin:$PATH" tool build:*)'; + return malformedBashRule(fixtureRepo, rule).length === 1; + }); + expect('a path under the audited checkout is not machine-specific', () => { + return malformedBashRule(fixtureRepo, 'Bash(/tmp/example/repo/scripts/x.sh:*)').length === 0; + }); + expect('an ordinary command in a Bash rule is fine', () => { + return malformedBashRule(fixtureRepo, 'Bash(pnpm lint*)').length === 0; + }); + expect('a URL in a Bash rule is not read as a filesystem path', () => { + return malformedBashRule(fixtureRepo, 'Bash(curl https://example.com/x:*)').length === 0; + }); + + console.log(`\n ${total - failed} passed · ${failed} failed\n`); + return failed === 0 ? 0 : 1; +} + +/* ── Reporting ───────────────────────────────────────────────────────────── */ + +const SEVERITY_SUFFIX = { error: '', warn: ' [warn]', info: ' [info]' }; + +function report(findings, options, selected) { + const counts = { + error: findings.filter((finding) => finding.severity === 'error').length, + warn: findings.filter((finding) => finding.severity === 'warn').length, + info: findings.filter((finding) => finding.severity === 'info').length, + }; + + if (options.json) { + console.log( + JSON.stringify({ tool: 'agent-doctor', rules: selected, counts, findings }, null, 2), + ); + } else { + const lines = findings.flatMap((finding) => [ + `${finding.file}:${finding.line}: ${finding.rule} ${finding.message}${SEVERITY_SUFFIX[finding.severity]}`, + ...(finding.note ? [` note: ${finding.note}`] : []), + ]); + if (lines.length > 0) process.stdout.write(`${lines.join('\n')}\n`); + } + + process.stderr.write( + `agent-doctor: ${counts.error} error(s), ${counts.warn} warning(s), ${counts.info} note(s)` + + ` across ${selected.length} rule(s): ${selected.join(', ')}` + + `${options.local ? '' : ' (committed only; pass --local for the local checks)'}\n`, + ); + + if (options.report) return 0; + return counts.error > 0 || (options.strict && counts.warn > 0) ? 1 : 0; +} + +/* ── Main ────────────────────────────────────────────────────────────────── */ + +const HELP = ` +agent-doctor — agent-configuration drift gate for Dicee + + node scripts/agent-doctor.mjs committed checks only (CI-safe default) + node scripts/agent-doctor.mjs --local also run the local, opportunistic checks + node scripts/agent-doctor.mjs --report print every finding, always exit 0 + node scripts/agent-doctor.mjs --strict warnings are fatal too + node scripts/agent-doctor.mjs --json deterministic machine-readable output + node scripts/agent-doctor.mjs --only A,B run a subset of the rules + node scripts/agent-doctor.mjs --root audit another checkout + node scripts/agent-doctor.mjs --self-test exercise the pure helpers + +Committed rules: ${COMMITTED_RULES.join(', ')} +Local rules: ${LOCAL_RULES.join(', ')} + +Read-only and offline. Prints no secret, no account identifier and no email address. +`; + +function main() { + let options; + try { + options = parseArgs(process.argv.slice(2)); + } catch (error) { + if (!(error instanceof DoctorError)) throw error; + process.stderr.write(`agent-doctor: ${error.message}\n`); + return 2; + } + if (options.help) { + console.log(HELP); + return 0; + } + if (options.selfTest) return selfTest(); + + let repo; + try { + repo = loadRepository(options.root); + } catch (error) { + if (!(error instanceof DoctorError)) throw error; + process.stderr.write(`agent-doctor: ${error.message}\n`); + return 2; + } + + const available = options.local ? RULES : COMMITTED_RULES; + const selected = (options.only ?? available).filter((rule) => available.includes(rule)); + + const findings = []; + // Local findings quote real configuration, so every message is redacted on the way in + // rather than at each call site: no output of this gate ever names a home directory. + const add = (file, line, rule, severity, message, note) => { + const finding = { file, line, rule, severity, message: tilde(message) }; + findings.push(note ? { ...finding, note: tilde(note) } : finding); + }; + for (const rule of selected) RULE_IMPLEMENTATIONS[rule]({ repo, add }); + + findings.sort( + (a, b) => + (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) || + a.line - b.line || + (a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0) || + (a.message < b.message ? -1 : a.message > b.message ? 1 : 0), + ); + + return report(findings, options, selected); +} + +// `process.exitCode` rather than `process.exit`, so a piped stdout is never truncated. +process.exitCode = main(); diff --git a/scripts/check-1password-setup.sh b/scripts/check-1password-setup.sh index 0f8ca10..8621737 100755 --- a/scripts/check-1password-setup.sh +++ b/scripts/check-1password-setup.sh @@ -41,27 +41,18 @@ dicee_require_op log "Dicee local bootstrap secret contract" log "1Password account: $DICEE_OP_ACCOUNT" log "1Password vault: $DICEE_OP_VAULT" -log "Infisical runtime authority remains: $DICEE_INFISICAL_PROJECT_SLUG ($DICEE_INFISICAL_INSTANCE_URL)" log -check_field "$DICEE_OP_ITEM_INFISICAL_DEV" client-id "development Infisical client-id" -check_field "$DICEE_OP_ITEM_INFISICAL_DEV" client-secret "development Infisical client-secret" -check_field "$DICEE_OP_ITEM_INFISICAL_STAGING" client-id "staging Infisical client-id" -check_field "$DICEE_OP_ITEM_INFISICAL_STAGING" client-secret "staging Infisical client-secret" -check_field "$DICEE_OP_ITEM_INFISICAL_PROD" client-id "production Infisical client-id" -check_field "$DICEE_OP_ITEM_INFISICAL_PROD" client-secret "production Infisical client-secret" check_field "$DICEE_OP_ITEM_CLOUDFLARE" api-token "Cloudflare API token" - -check_field "$DICEE_OP_ITEM_VERCEL" token "dicee-vercel-api/token" true -check_field "$DICEE_OP_ITEM_PARTYKIT" token "dicee-partykit-api/token" true check_field "$DICEE_OP_ITEM_ELEVENLABS_LOCAL" api-key "ElevenLabs API key" true if [[ "$quiet" == false ]]; then + # Display-only identifiers. Missing ones must not fail the credential check, + # which is what this script exists to report. cat <&2 + return 1 + fi + readonly "$name" + done +} + dicee_require_command() { local command_name="$1" if ! command -v "$command_name" >/dev/null 2>&1; then diff --git a/scripts/public-safety-scan.sh b/scripts/public-safety-scan.sh index 543190a..ffca9a5 100755 --- a/scripts/public-safety-scan.sh +++ b/scripts/public-safety-scan.sh @@ -93,6 +93,8 @@ scan_pattern 'workstation-specific home path' '(/Users/|/home/|[A-Za-z]:\\Users\ scan_pattern 'credential-manager item URI' 'op://' scan_pattern 'hosted database project hostname' '\b[a-z]{20}\.supabase\.(co|com)\b' scan_pattern 'hosted database project reference' "project[_-]ref\b[\"']?[[:space:]]*[=:]?[[:space:]]*[\"']?[a-z]{20}\b" +# Residual guard for a retired secret manager: a self-hosted instance hostname is +# private infrastructure and must never reach the publication candidate. scan_pattern 'private infrastructure hostname' 'https?://infisical\.(?!com(?:[/[:space:]]|$)|example\.)[A-Za-z0-9.-]+' scan_pattern 'access token transported in a URL' '[?&](access_)?token=' scan_pattern 'write-enabled hosted MCP configuration' 'read_only=false' is_config_file @@ -115,6 +117,7 @@ while IFS= read -r -d '' file; do [[ -f "$file" ]] || continue [[ "$file" == "scripts/public-safety-scan.sh" ]] && continue # Apply private-name rules at every depth, including package-local env files. + # The .infisical.json entry is a residual guard for a retired secret manager. case "${file##*/}" in .env.example|.infisical.example.json) ;; .env|.env.*|*.pem|*.key|*.p12|*.pfx|.infisical.json) diff --git a/scripts/templates/dicee-operator-metadata.example.sh b/scripts/templates/dicee-operator-metadata.example.sh index fdddf17..939b963 100644 --- a/scripts/templates/dicee-operator-metadata.example.sh +++ b/scripts/templates/dicee-operator-metadata.example.sh @@ -6,33 +6,17 @@ # file. This file contains identifiers only; credential values remain in the # configured secret manager. +# Required by every credential launcher; the loader fails closed without them. DICEE_OP_ACCOUNT="your-account.1password.com" DICEE_OP_VAULT="your-private-vault" - -DICEE_OP_ITEM_INFISICAL_DEV="your-infisical-dev-item" -DICEE_OP_ITEM_INFISICAL_STAGING="your-infisical-staging-item" -DICEE_OP_ITEM_INFISICAL_PROD="your-infisical-production-item" DICEE_OP_ITEM_CLOUDFLARE="your-cloudflare-item" -DICEE_OP_ITEM_VERCEL="your-optional-vercel-item" -DICEE_OP_ITEM_PARTYKIT="your-optional-partykit-item" -DICEE_OP_ITEM_ELEVENLABS_LOCAL="your-optional-elevenlabs-item" - -DICEE_INFISICAL_INSTANCE_URL="https://infisical.example.com" -DICEE_INFISICAL_PROJECT_ID="00000000-0000-4000-8000-000000000000" -DICEE_INFISICAL_PROJECT_SLUG="your-project-slug" -DICEE_INFISICAL_ORG_NAME="your-organization" +DICEE_OP_ITEM_ELEVENLABS_LOCAL="your-local-audio-item" +DICEE_CLOUDFLARE_ACCOUNT_ID="00000000000000000000000000000000" -DICEE_INFISICAL_DEV_IDENTITY_NAME="your-development-identity" -DICEE_INFISICAL_DEV_IDENTITY_ID="00000000-0000-4000-8000-000000000000" -DICEE_INFISICAL_STAGING_IDENTITY_NAME="your-staging-identity" -DICEE_INFISICAL_STAGING_IDENTITY_ID="00000000-0000-4000-8000-000000000000" -DICEE_INFISICAL_PROD_IDENTITY_NAME="your-production-identity" -DICEE_INFISICAL_PROD_IDENTITY_ID="00000000-0000-4000-8000-000000000000" +# Required by the scripts that declare them, not by the launchers. +DICEE_SUPABASE_PROJECT_REF="your-project-ref" -DICEE_CLOUDFLARE_ACCOUNT_ID="00000000000000000000000000000000" +# Non-secret identifiers kept for operator reference. DICEE_CLOUDFLARE_DOMAIN="example.com" DICEE_SUPABASE_PROJECT_NAME="your-project" -DICEE_SUPABASE_PROJECT_REF="your-project-ref" -DICEE_VERCEL_PROJECT_NAME="your-optional-vercel-project" -DICEE_PARTYKIT_PROJECT_NAME="your-optional-partykit-project" DICEE_AUDIO_PROJECT_NAME="your-audio-project" diff --git a/scripts/tests/agent-doctor.test.sh b/scripts/tests/agent-doctor.test.sh new file mode 100644 index 0000000..a3a25e0 --- /dev/null +++ b/scripts/tests/agent-doctor.test.sh @@ -0,0 +1,523 @@ +#!/usr/bin/env bash +# Exercise every scripts/agent-doctor.mjs rule against disposable fixture checkouts. +# Each case builds a fresh Git repository from synthetic files and runs the real gate with +# --root, so the working tree of this repository is never inspected and no assertion +# depends on what happens to be installed or configured on this machine. + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +GATE="$PROJECT_ROOT/scripts/agent-doctor.mjs" + +# scripts/check-docs.mjs reads every tracked shell script for `.claude/...` references that +# must resolve. The personal settings file this gate inspects is ignored by design and +# never resolves, so its directory is named once and every fixture path is built from it. +CLAUDE_DIR='.claude' + +# Git hooks export repository-local variables that override `git -C`. +# Follow githooks(5)'s foreign-repository guidance before creating the fixtures. +git_local_env_vars="$(git rev-parse --local-env-vars)" +while IFS= read -r name; do + unset "$name" +done <<<"$git_local_env_vars" +export GIT_CONFIG_NOSYSTEM=1 + +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/dicee-agent-doctor-test.XXXXXX")" +trap 'rm -rf "$work_dir"' EXIT +gate_output="$work_dir/gate-output" +# A fixture-owned global Git configuration, so the IDENTITY rule sees a known world +# instead of the developer's own identity routing. +global_config="$work_dir/gitconfig-global" +identity_include="$work_dir/gitconfig-included" +export GIT_CONFIG_GLOBAL="$global_config" +printf '[user]\n\temail = fixture@example.com\n' >"$identity_include" + +repo="" +case_count=0 +failures=0 + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + failures=$((failures + 1)) +} + +# put : write stdin to a fixture file, creating parent directories. +put() { + mkdir -p "$(dirname "$repo/$1")" + cat >"$repo/$1" +} + +# patch_json : run against the parsed document `j`, then rewrite it. +# Reads through the repository's own JSONC reader so a fixture that carries comments can +# still be patched; the rewrite is plain JSON, which every client file format accepts. +patch_json() { + node --input-type=module -e ' + import { readFileSync, writeFileSync } from "node:fs"; + import { pathToFileURL } from "node:url"; + const [file, body, reader] = process.argv.slice(1); + const { stripJsonc } = await import(pathToFileURL(reader).href); + const j = JSON.parse(stripJsonc(readFileSync(file, "utf8"))); + new Function("j", body)(j); + writeFileSync(file, `${JSON.stringify(j, null, "\t")}\n`); + ' "$repo/$1" "$2" "$PROJECT_ROOT/scripts/cloudflare-config-audit.mjs" +} + +# new_repo: a fixture that passes every committed check. +new_repo() { + case_count=$((case_count + 1)) + repo="$work_dir/case-$case_count" + git -c init.defaultBranch=main init --quiet "$repo" + printf '[user]\n\temail = fixture@example.com\n' >"$global_config" + + put AGENTS.md <<'EOF' +# Agents + +The repository contract. +EOF + put scripts/check-docs.config.json <<'EOF' +{ + "budgets": { "AGENTS.md": { "maxLines": 10 } } +} +EOF + put "$CLAUDE_DIR/settings.json" <<'EOF' +{ + "permissions": { + "allow": ["Bash(pnpm lint*)"], + "deny": ["mcp__supabase__execute_sql", "mcp__supabase__apply_migration"] + }, + "enabledMcpjsonServers": ["akg", "cloudflare-docs"] +} +EOF + put .mcp.json <<'EOF' +{ + "mcpServers": { + "akg": { + "command": "mise", + "args": ["exec", "--", "bun", "run", "packages/web/src/tools/akg/mcp/server.ts"], + "env": { "AKG_PROJECT_ROOT": "." } + }, + "cloudflare-docs": { "type": "http", "url": "https://docs.example.com/mcp" }, + "cloudflare-api": { "type": "http", "url": "https://api.example.com/mcp" }, + "supabase": { + "type": "http", + "url": "https://db.example.com/mcp?project_ref=${FIXTURE_REF}&read_only=true" + } + } +} +EOF + put .cursor/mcp.json <<'EOF' +{ + // Cursor accepts comments in this file. + "mcpServers": { + "akg": { + "type": "stdio", + "command": "mise", + "args": ["exec", "--", "bun", "run", "packages/web/src/tools/akg/mcp/server.ts"] + }, + "cloudflare-docs": { "url": "https://docs.example.com/mcp" } + } +} +EOF + put .vscode/mcp.json <<'EOF' +{ + "servers": { + "akg": { + "type": "stdio", + "command": "mise", + "args": ["exec", "--", "bun", "run", "packages/web/src/tools/akg/mcp/server.ts"] + }, + "cloudflare-docs": { "type": "http", "url": "https://docs.example.com/mcp" } + } +} +EOF + put .codex/config.toml <<'EOF' +# Repository-scoped Codex configuration. +[agents] +max_depth = 1 + +[mcp_servers.akg] +command = "mise" +args = ["exec", "--", "bun", "run", "packages/web/src/tools/akg/mcp/server.ts"] +env = { AKG_PROJECT_ROOT = "." } + +[mcp_servers.cloudflare-docs] +url = "https://docs.example.com/mcp" +EOF + put packages/web/src/tools/akg/mcp/server.ts <<'EOF' +server.registerTool('akg_check_import', {}); +server.registerTool('akg_layer_rules', {}); +EOF +} + +commit_fixture() { + git -C "$repo" add -A + git -C "$repo" -c user.name=t -c user.email=t@example.com commit --quiet --allow-empty -m fixture +} + +run_gate() { + commit_fixture + gate_status=0 + node "$GATE" --root "$repo" "$@" >"$gate_output" 2>&1 || gate_status=$? +} + +show_output() { + printf 'Gate output:\n' >&2 + sed 's/^/ /' "$gate_output" >&2 +} + +# finding_re : a finding line for that rule, whatever path it is anchored to. +finding_re() { + printf '^.+:[0-9]+: %s ' "$1" +} + +# expect_pass