Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ Open only what the task needs:
- [docs/testing.md](docs/testing.md): what each gate proves, browser matrices, staging, artifacts, and cleanup.
- [docs/operations.md](docs/operations.md): CI, Vercel hosting, staging, deployment, rollback, and live verification.
- [supabase/README.md](supabase/README.md): Edge Functions, shared backend modules, migrations, auth boundaries, and required secret names.
- [plans/README.md](plans/README.md): work in progress, the backlog, and shipped plans. Every plan lives in `plans/`; write new plans there. Revalidate every plan against current source before executing it.
- [plans/README.md](plans/README.md): work in progress, the backlog, and shipped plans. Every plan lives in `plans/` and opens with a `**Status:**` line; write new plans there. Commit a plan before splitting it across agent threads, and put its path in each thread's prompt. Revalidate every plan against current source before executing it.
- [docs/README.md](docs/README.md): complete documentation index and freshness labels.

When commands, ports, workflows, or runtime boundaries change, update the owning focused document in the same change. Do not copy large source trees, schemas, dependency lists, or model catalogs into documentation.
8 changes: 4 additions & 4 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ If Actions jobs fail within 2 to 5 seconds with no runner and no logs, the accou

## Frontend hosting on Vercel

Vercel builds and serves the frontend from the Git repository. Merging to `main` starts a production deployment to `https://debate.pi-stack.org`; pull requests may get preview deployments. The production canary runs after each successful Vercel `Production` deployment.
Vercel builds and serves the frontend from the Git repository. Merging to `main` starts a production deployment to `https://debate.pi-stack.org`. Pull requests may get Vercel preview deployments, and those use the production Supabase project unless the in-app switcher picks staging, so treat a preview as production data. For a staging-pinned preview, use `npm run dev:preview` (see [development.md](development.md#preview-on-the-tailnet)). The production canary runs after each successful Vercel `Production` deployment.

[`vercel.json`](../vercel.json) owns:

Expand Down Expand Up @@ -63,14 +63,14 @@ The staging browser suite disables trace, screenshot, and video capture. It crea

Do not enable the staging repository variable before every prerequisite is present. A skipped staging job is not a pass.

`STAGING_E2E_ENABLED` has been `true` since 2026-09-18, and the job passed through 2026-09-23. Since then it has been skipped because the `Quality` run it waits for fails before any runner starts (see [When GitHub Actions cannot run](#when-github-actions-cannot-run)). Restoring Actions minutes restores the staging job; nothing in the workflow needs changing.
`STAGING_E2E_ENABLED` has been `true` since 2026-09-18. The job runs after every successful `Quality` run from a push to `main`. Quality runs from pull requests also trigger it, and those show as skipped by design. Check the latest real result with `gh run list --workflow staging-e2e.yml` and `npm run ci:failures -- <run-id>`, not this page. The run uploads no artifact, so the job log is the only evidence.

## Staging sync

Staging is project `debate-trainer-staging` (`brifeyypjauyusmkptkx`). Production is `debate-trainer` (`uziwdtkzofptihahfooo`). Every backend change goes to staging first, passes the staging browser suite, and only then goes to production. Steps 1 and 2 are read-only. Steps 3 and 4 change staging and need Adam's authorization each time.

1. Confirm the target. `supabase projects list` must show the staging ref under the staging name. Stop if a command, link, or environment variable points at the production ref.
2. Find what is pending. Run `npm run backend:status -- --project-ref brifeyypjauyusmkptkx --check` from a checkout of the commit you mean to ship. It compares staging with the committed tree and exits 1 when a migration is not applied. Where that script is not available, run its two read-only queries directly:
2. Find what is pending. Run `npm run backend:status -- --project-ref brifeyypjauyusmkptkx --check` from a checkout of the commit you mean to ship. It compares staging with the committed tree and exits 1 when a migration is not applied. It also lists expected function secrets the project lacks (names only) and prints the Auth site URL and redirect allowlist. Where that script is not available, run its two read-only queries directly:

```bash
supabase migration list --project-ref brifeyypjauyusmkptkx
Expand All @@ -94,7 +94,7 @@ The switch only works while the `connect-src` directive in [`vercel.json`](../ve

## Backend drift check

`npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the project the Supabase CLI resolves, which is `SUPABASE_PROJECT_ID` when that is set and the linked project otherwise; `npm run backend:status -- --project-ref <ref>` inspects another one. It runs `supabase migration list` and `supabase functions list` and changes no schema, data, functions, or secrets. To connect to the database, the CLI creates its usual short-lived login role.
`npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the project the Supabase CLI resolves, which is `SUPABASE_PROJECT_ID` when that is set and the linked project otherwise; `npm run backend:status -- --project-ref <ref>` inspects another one. It runs `supabase migration list`, `supabase functions list` and `supabase secrets list`, reads the Auth site URL and redirect allowlist from the Management API with the CLI's access token (`SUPABASE_ACCESS_TOKEN`, or the `access-token` file in `SUPABASE_HOME`, default `~/.supabase`), and changes no schema, data, functions, or secrets. It prints secret names only, never values or digests. To connect to the database, the CLI creates its usual short-lived login role.

The comparison uses the committed tree at `HEAD`, so commit or stash local edits first. It reports:

Expand Down
2 changes: 2 additions & 0 deletions plans/2026-09-20_131317-debate-workspace.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Debate workspace implementation plan

**Status:** On hold and superseded by [the reduced-scope plan](2026-09-21-debate-workspace-no-bs.md). Collaborative Workspace is hidden in production. Kept as evidence.

> For agentic workers: use `superpowers:executing-plans` or, when delegated execution is authorized, `superpowers:subagent-driven-development`. This is a master plan. Execute one reviewed slice at a time and keep its checkboxes current. Planning does not authorize implementation or deployment.

**Goal:** Give a school debate squad one persistent, collaborative place to prepare both sides of a motion, develop speeches, share evidence, use AI, practise, review feedback, and export its work.
Expand Down
2 changes: 2 additions & 0 deletions plans/2026-09-21-debate-workspace-no-bs.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Debate Workspace: no-BS replacement plan

**Status:** On hold. Collaborative Workspace is hidden in production and renders only when `VITE_COLLABORATION_URL` is set.

Adam's first working journey: sign in, enter a motion once, land in Intro, invite a teammate by code, assign R1, and write in the same paragraph from two browsers. Switch between A favor and En contra, disconnect one browser, reconnect, reload, and reopen the workspace tomorrow with the same text and last-opened file. Create another workspace with different collaborators and still find the first in the archive.

**First-milestone done means** that journey works against real local Auth, PostgreSQL and the collaboration service through a private Tailscale URL. Saved survives a service restart; undo leaves the teammate's work intact; removal blocks subsequent reads and writes. File controls, history/restore, human suggestions and comments work on desktop and mobile. A shell, mocked session, or passing spike is not this deliverable.
Expand Down
2 changes: 2 additions & 0 deletions plans/2026-09-25_074255-current-debate-foundation.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# PR 1: Current Debate

**Status:** Shipped in #72 (merged 2026-09-27 as `5ee0b05`). Kept as the record.

## What Adam wants

DebateLab's existing features feel like separate tools. A student enters the same motion repeatedly, manually carries material between pages, and can accidentally use sources from an older topic. Adam wants a lightweight personal debate context now, without rebuilding the postponed collaborative Workspace.
Expand Down
2 changes: 2 additions & 0 deletions plans/2026-09-25_074255-debate-ai-quality-modules.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# PR 2: Debate-aware AI and smaller modules

**Status:** Shipped in #75 (merged 2026-09-28 as `f0f2f75`). Kept as the record.

## What Adam wants

Once Current Debate exists, every DebateLab feature should understand the same preparation instead of behaving like a blank tool. The model should receive useful debate context automatically, while the student can see and change what will be sent. The current prompts also need deliberate improvement, and the oversized AI Edge Function needs smaller internal files so future fixes do not require understanding an 88 KB entrypoint.
Expand Down
2 changes: 1 addition & 1 deletion plans/2026-10-01-voice-sparring-crossfire.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Voice Sparring and Crossfire

**Status:** Plan. Written 2026-10-01 against `main` at `fe6100a`. It records only the general choices. Implementation details are up to the implementer.
**Status:** Shipped in #84 (merged 2026-10-02 as `791ccb0`). Kept as the record. Written 2026-10-01 against `main` at `fe6100a`. It records only the general choices. Implementation details are up to the implementer.

## Why

Expand Down
4 changes: 2 additions & 2 deletions plans/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Status:** Plan index. Plans describe intended work, not current implementation truth. Reconciled against `main` at `791ccb0` on 2026-10-02.

Every plan lives in this folder. Before executing one, recheck its paths, assumptions, product decisions, and verification commands against current source.
Every plan lives in this folder and opens with a `**Status:**` line; a plan listed under Shipped must say Shipped (`npm run docs:check` enforces both). When you split a plan across several agent threads, commit it first and put its path in each thread's prompt: an uncommitted plan exists only in one worktree. Before executing one, recheck its paths, assumptions, product decisions, and verification commands against current source.

## In progress

Expand All @@ -18,7 +18,7 @@ Nothing is in progress.

## Shipped

These plans are finished. Their files were removed; git history keeps them.
These plans are finished. Most of their files were removed and git history keeps them. The three linked below are kept as the record, and each says `**Status:** Shipped`.

- Voice Sparring and Crossfire: #84. Plan kept as the record: [2026-10-01 voice sparring and crossfire](2026-10-01-voice-sparring-crossfire.md).
- Debate-aware AI and smaller modules: #75. Plan kept as the record: [2026-09-25 debate AI quality modules](2026-09-25_074255-debate-ai-quality-modules.md).
Expand Down
80 changes: 73 additions & 7 deletions scripts/backend-status.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@
//
// Vercel ships the frontend on merge; migrations and Edge Functions are
// deployed by hand. This script lists what the current commit has that the
// project does not. It only runs `supabase migration list` and
// `supabase functions list`, and it reads the repository from git HEAD, so
// uncommitted files are ignored.
// project does not. It only runs `supabase migration list`,
// `supabase functions list` and `supabase secrets list` (names only), reads the
// project's Auth URL settings from the Management API, and reads the
// repository from git HEAD, so uncommitted files are ignored.
//
// Usage: npm run backend:status [-- --project-ref <ref>] [--check]
// --project-ref <ref> inspect this project instead of the linked one
// --check exit 1 when a migration on this commit is not applied

import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, posix } from "node:path";
import { pathToFileURL } from "node:url";

Expand All @@ -21,6 +23,17 @@ const KNOWN_PROJECTS = {
brifeyypjauyusmkptkx: "staging",
};

// Secrets the functions read that Supabase does not inject. DEMO_CODE_PEPPER
// and SPARRING_INTEGRITY_SECRET fall back to the service-role key, and the
// WORKSPACE_INVITE_* values serve the hidden Workspace, so they are not listed.
export const EXPECTED_SECRETS = {
OPENROUTER_API_KEY: "every model request",
EXA_API_KEY: "web research in case generation and Ask",
STRIPE_SECRET_KEY: "checkout, billing portal, webhook and account deletion",
STRIPE_WEBHOOK_SECRET: "stripe-webhook signature check",
ELEVENLABS_API_KEY: "opponent voice; without it voice rounds use the browser voice",
};

const MIGRATION_FILE = /^(\d+)_.+\.sql$/;
const STATIC_IMPORT = /\b(?:import|export)\b[^;]*?\bfrom\s*["']([^"']+)["']/g;
const BARE_IMPORT = /\bimport\s*["']([^"']+)["']/g;
Expand Down Expand Up @@ -252,6 +265,57 @@ export function formatReport({ target, local, result, deployedCount, notes }) {
return lines.join("\n");
}

export function missingSecrets(names) {
const present = new Set(names);
return Object.entries(EXPECTED_SECRETS)
.filter(([name]) => !present.has(name))
.map(([name, use]) => `${name}: ${use}`);
}

function secretsReport(root, targetArgs) {
let names;
try {
const output = supabaseJson(root, ["secrets", "list", ...targetArgs]);
names = (Array.isArray(output) ? output : (output.secrets ?? [])).map((secret) => secret.name);
} catch (error) {
return `Secrets: not checked (${error.message.split("\n")[0]}).`;
}
const missing = missingSecrets(names);
if (missing.length === 0) return "Secrets: every expected function secret is set.";
return ["Secrets: missing expected function secrets (names only):", ...missing.map((line) => ` ${line}`)].join("\n");
}

// The CLI's file-backed token. A token kept only in the OS keyring can't be
// read here; set SUPABASE_ACCESS_TOKEN for this command instead.
function accessToken() {
const fromEnv = process.env.SUPABASE_ACCESS_TOKEN?.trim();
if (fromEnv) return fromEnv;
const file = join(process.env.SUPABASE_HOME?.trim() || join(homedir(), ".supabase"), "access-token");
return existsSync(file) ? readFileSync(file, "utf8").trim() : null;
}

/** Site URL and redirect allowlist, which magic links and OAuth redirects must match. */
export async function authUrlsReport(ref) {
if (!/^[a-z0-9]{20}$/.test(ref)) return "Auth URLs: not checked (unknown project ref).";
const token = accessToken();
if (!token) return "Auth URLs: not checked (no access token file; if the CLI keeps it in the OS keyring, set SUPABASE_ACCESS_TOKEN).";
try {
const response = await fetch(`https://api.supabase.com/v1/projects/${ref}/config/auth`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) return `Auth URLs: not checked (Management API answered ${response.status}).`;
const config = await response.json();
const allowList = String(config.uri_allow_list ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
return [
`Auth URLs: site_url ${config.site_url || "(unset)"}`,
" Redirect allowlist:",
...(allowList.length ? allowList.map((entry) => ` ${entry}`) : [" (empty)"]),
].join("\n");
} catch (error) {
return `Auth URLs: not checked (${error.message}).`;
}
}

export function backendStatus({ root, projectRef }) {
const local = readCommit(root);
const targetArgs = projectRef ? ["--project-ref", projectRef] : [];
Expand Down Expand Up @@ -280,10 +344,11 @@ export function backendStatus({ root, projectRef }) {
const target = label ? `${ref} (${label})` : ref;

const result = compareBackend({ local, remoteMigrations, deployedFunctions, changeOf });
return { result, report: formatReport({ target, local, result, deployedCount: deployedFunctions.length, notes }) };
const report = formatReport({ target, local, result, deployedCount: deployedFunctions.length, notes });
return { ref, result, report: `${report}\n\n${secretsReport(root, targetArgs)}` };
}

function main() {
async function main() {
let options;
try {
options = parseArgs(process.argv.slice(2));
Expand All @@ -295,8 +360,9 @@ function main() {

try {
const root = git(process.cwd(), ["rev-parse", "--show-toplevel"]).trim();
const { result, report } = backendStatus({ root, projectRef: options.projectRef });
const { ref, result, report } = backendStatus({ root, projectRef: options.projectRef });
console.log(report);
console.log(`\n${await authUrlsReport(ref)}`);
if (options.check && result.pendingMigrations.length > 0) process.exit(1);
} catch (error) {
console.error(error.message);
Expand All @@ -307,4 +373,4 @@ function main() {
}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
53 changes: 53 additions & 0 deletions scripts/check-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const EXCLUDED_PREFIXES = [
];

const PLAN_INDEX = "plans/README.md";
const PLAN_STATUS = /^\*\*Status:\*\*[ \t]*(.*)$/m;
const PLAN_STATUS_LINES = 15;
const EXTERNAL_TARGET = /^(?:https?:|mailto:|tel:|data:|\/\/)/i;
const RETIRED_CHECKOUT = [
/\b(?:cd|pushd)\s+(?:["'`])?debate-navigator(?=[/\\\s"'`]|$)/i,
Expand Down Expand Up @@ -107,6 +109,56 @@ function localTarget(rootDir, documentPath, rawTarget) {
return { absolutePath };
}

// Plans are excluded from link checks, but each must say where it stands, and
// the index's Shipped section must not list a plan that still calls itself a plan.
async function checkPlans(root, issues) {
let entries;
try {
entries = await readdir(resolve(root, "plans"), { withFileTypes: true });
} catch {
return;
}

const statuses = new Map();
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "README.md") continue;
const content = await readFile(resolve(root, "plans", entry.name), "utf8");
const head = content.split(/\r?\n/).slice(0, PLAN_STATUS_LINES).join("\n");
const status = head.match(PLAN_STATUS)?.[1].trim() || null;
statuses.set(entry.name, status);
if (!status) {
issues.push({ file: `plans/${entry.name}`, line: 1, message: `plan has no **Status:** line in its first ${PLAN_STATUS_LINES} lines` });
}
}

let index;
try {
index = await readFile(resolve(root, PLAN_INDEX), "utf8");
} catch {
return;
}
let section = "";
for (const [lineIndex, line] of index.split(/\r?\n/).entries()) {
const heading = line.match(/^##\s+(.+?)\s*$/);
if (heading) {
section = heading[1].toLowerCase();
continue;
}
if (section !== "shipped") continue;
for (const match of line.matchAll(INLINE_LINK)) {
const name = normalizeTarget(match[1]).replace(/[?#].*$/, "").replace(/^\.\//, "");
const status = statuses.get(name);
if (status && !/^shipped\b/i.test(status)) {
issues.push({
file: PLAN_INDEX,
line: lineIndex + 1,
message: `${name} is listed under Shipped but its status is not "Shipped": ${status}`,
});
}
}
}
}

export async function checkDocs(rootDir) {
const root = resolve(rootDir);
const issues = [];
Expand Down Expand Up @@ -152,6 +204,7 @@ export async function checkDocs(rootDir) {
}
}

await checkPlans(root, issues);
return issues;
}

Expand Down
Loading
Loading