From 779ba6db59bd93de6bd525aa284ee5a4c172303a Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 24 May 2026 04:31:11 -0700 Subject: [PATCH] Slice 1: scaffolding + propose-to-super-draft vertical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the §1 bot wrapper, the §4 cache (webhook + reconciler), the §5 schema (six numbered migrations), Gitea OAuth + §6 user provisioning, the §7 catalog left pane, and the propose-to-merge vertical: propose modal opens an idea PR against the meta repo, an owner merges from the pending-idea view, the cache picks it up via webhook or reconciler sweep, and the catalog renders the new super-draft. Per §1 the bot is the only Git writer; every commit, branch creation, and PR merge carries the §6.5 On-behalf-of: trailer and an `actions` audit row. Per §4 the cache is never written from a user action — it's webhook+reconciler only. Covered by `backend/tests/test_propose_vertical.py` against an in-process Gitea simulator. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 25 + IMPLEMENTATION-PROMPT.md | 62 + PHILOSOPHY.md | 228 ++ README.md | 199 ++ SPEC.md | 2605 +++++++++++++++++ backend/.env.example | 43 + backend/app/__init__.py | 0 backend/app/api.py | 396 +++ backend/app/auth.py | 195 ++ backend/app/bot.py | 222 ++ backend/app/cache.py | 312 ++ backend/app/config.py | 80 + backend/app/db.py | 84 + backend/app/entry.py | 102 + backend/app/gitea.py | 286 ++ backend/app/main.py | 102 + backend/app/webhooks.py | 71 + backend/migrations/001_users_and_audit.sql | 65 + backend/migrations/002_cache.sql | 82 + .../migrations/003_branches_grants_stars.sql | 38 + .../migrations/004_threads_and_changes.sql | 73 + .../005_seen_cursors_and_watches.sql | 45 + backend/migrations/006_notifications.sql | 57 + backend/requirements.txt | 10 + backend/tests/test_propose_vertical.py | 440 +++ docs/DEV.md | 153 + frontend/index.html | 12 + frontend/package-lock.json | 1707 +++++++++++ frontend/package.json | 27 + frontend/src/App.css | 319 ++ frontend/src/App.jsx | 90 + frontend/src/api.js | 70 + frontend/src/components/Catalog.jsx | 155 + frontend/src/components/Landing.jsx | 24 + frontend/src/components/ProposalView.jsx | 168 ++ frontend/src/components/ProposeModal.jsx | 150 + frontend/src/components/RFCView.jsx | 55 + frontend/src/index.css | 14 + frontend/src/main.jsx | 13 + frontend/vite.config.js | 15 + mockups/main-pane.html | 1434 +++++++++ scripts/seed_meta_repo.py | 157 + 42 files changed, 10385 insertions(+) create mode 100644 .gitignore create mode 100644 IMPLEMENTATION-PROMPT.md create mode 100644 PHILOSOPHY.md create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 backend/.env.example create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api.py create mode 100644 backend/app/auth.py create mode 100644 backend/app/bot.py create mode 100644 backend/app/cache.py create mode 100644 backend/app/config.py create mode 100644 backend/app/db.py create mode 100644 backend/app/entry.py create mode 100644 backend/app/gitea.py create mode 100644 backend/app/main.py create mode 100644 backend/app/webhooks.py create mode 100644 backend/migrations/001_users_and_audit.sql create mode 100644 backend/migrations/002_cache.sql create mode 100644 backend/migrations/003_branches_grants_stars.sql create mode 100644 backend/migrations/004_threads_and_changes.sql create mode 100644 backend/migrations/005_seen_cursors_and_watches.sql create mode 100644 backend/migrations/006_notifications.sql create mode 100644 backend/requirements.txt create mode 100644 backend/tests/test_propose_vertical.py create mode 100644 docs/DEV.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api.js create mode 100644 frontend/src/components/Catalog.jsx create mode 100644 frontend/src/components/Landing.jsx create mode 100644 frontend/src/components/ProposalView.jsx create mode 100644 frontend/src/components/ProposeModal.jsx create mode 100644 frontend/src/components/RFCView.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/vite.config.js create mode 100644 mockups/main-pane.html create mode 100755 scripts/seed_meta_repo.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a047fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ + +# Node +node_modules/ +dist/ +.vite/ + +# Local state +.env +.env.local +*.db +*.db-journal +*.db-wal +*.db-shm +data/ + +# OS +.DS_Store +.idea/ +.vscode/ diff --git a/IMPLEMENTATION-PROMPT.md b/IMPLEMENTATION-PROMPT.md new file mode 100644 index 0000000..0feaa8a --- /dev/null +++ b/IMPLEMENTATION-PROMPT.md @@ -0,0 +1,62 @@ +You're starting the implementation phase of the Wiggleverse RFC framework — a standardization process for the natural-language vocabulary that digital representations of humans, and the systems that interact with them, need to share. Thirteen settlement sessions have produced SPEC.md, which captures every structural decision v1 needs. Per §19.1, the spec is implementation-ready, and this session is the first build session. + +The application is a single-process FastAPI + SQLite + React + Vite + Tiptap (ProseMirror) app running against a Gitea instance. The app's bot service account performs all Git operations on behalf of users authenticated via Gitea OAuth; per §1, the bot is the only Git writer. Single-operator deployment shape (one Python process colocated with one SQLite file). The deliverable across all build sessions is the materialization of §§1–15 end-to-end; this first session establishes the spine and a single working vertical slice. + +Before writing any code: + +1. Read SPEC.md end to end. It is the binding contract. Every load-bearing decision is in there, and the implementation does not get to second-guess them. Pay particular attention to: + * §1 (the bot-as-only-actor framing and what it implies for the wrapper layer) + * §4 (the cache architecture — webhook + reconciler, both writing to the cache; user actions never write the cache directly) + * §5 (the canonical app tables — implement these with SQLite-appropriate column types and the appropriate FK / cascade rules) + * §6 (the four-role permission model and per-RFC delegated authority; this is enforced in the app, not in Gitea) + * §18 (the carryovers — these are confirmed unchanged from the prototype and should be re-used, not rewritten) + * §19.3 (the working agreement for the build phase) + +2. Read PHILOSOPHY.md. The spec's decisions answer to it. Where the spec's letter is silent, the philosophy's spirit decides — the transcript is the evidence, public-async work is the default, the system is a tool for thought rather than a feed. + +3. Glance at /Users/benstull/projects/wiggleverse/rfc-app-prototype/ — *reference only*. The §18 carryovers (FastAPI + SSE, Tiptap + ProseMirror, the `` / `` / `` / `` protocol, multi-provider LLM support, Gitea OAuth) have working code in the prototype; lift these directly where possible. Everything else in the prototype is superseded by the spec. The prototype's CSS, component decomposition, prompt-bar machinery, and DiffView rendering are likely re-usable; the prototype's data model, permission shape, and notification absence are not. + +Where to build: /Users/benstull/git/rfc-app/ is the project root. SPEC.md and PHILOSOPHY.md live at the root as content (versioned alongside the code). The implementation lives in subdirectories — backend/ for the FastAPI app and its SQLite migrations, frontend/ for the React + Vite + Tiptap app, docs/ for any operational documentation. + +Sequencing — this session establishes the spine and one working vertical slice, not every flow at once. The right slice order across all build sessions is: + +* **Slice 1 (this session):** repository scaffolding (backend/, frontend/, dev-environment script, a README explaining local bring-up); the full §5 schema as a migrations directory with appropriate indexes and FK rules; Gitea OAuth per §18 + user provisioning into `users`; the bot client wrapper that every Git operation flows through with the §6.5 `On-behalf-of:` trailer applied; the §4.1 webhook receiver and reconciler both writing to the metadata cache; a minimal left pane per §7 (catalog list, sort + filter chips + search, the "Pending ideas" disclosure); and *one end-to-end vertical*: propose a new RFC per §9.1 → idea PR opens against the meta repo → owner merges → super-draft appears in the catalog → super-draft view opens and renders the body. That slice exercises the bot wrapper, the cache, the permission model, the catalog, and the super-draft view in one path, which is the right shape for a first build session. +* **Slice 2:** the RFC view for active RFCs per §8 — the editor, branch creation, per-branch chat, AI participation, the change-card panel, accept/decline/edit, manual-edit flushes, sub-threads, flags, DiffView. +* **Slice 3:** the PR flow per §10 — opening, review surface, the seen-cursor mechanism, review threads, merge, post-merge, conflict resolution. +* **Slice 4:** super-draft body editing per §9.5 + §9.6 — meta-repo edit branches as the unit of work. +* **Slice 5:** graduation per §13 — the dialog, the transactional sequence, rollback, the pre-graduation history affordance. +* **Slice 6:** notifications per §15 — last, because every other surface produces signals that the notification surface receives, and notification correctness depends on those producers being in place first. +* **Slice 7:** the §14 chrome — landing page, /philosophy route, About link. +* **Slice 8:** hardening — end-to-end tests, dev/prod deployment shape, the 30/90 branch hygiene timers from §12. + +Working agreement (modified from §19.3 for the build phase): + +1. Implement the slice. The spec is the source of truth. The build does not extend it. +2. Where running code reveals the spec was wrong or underspecified at an *implementation-detail* level (the explicit "exact value is an implementation detail" callouts in §8.6, §8.11, §8.13, §8.15, etc.), make the decision and document it inline in code with a comment referencing the §. Do not amend the spec. +3. Where running code reveals the spec was wrong or underspecified at a *structural* level — a section that contradicts another, a decision that prevents a downstream flow from working, an assertion that breaks under real use — correct the spec in the appropriate numbered section with a brief note explaining what running code revealed. Match the spec's voice: declarative, structural, em-dashes for asides, prose rather than bullets where bullets don't earn their keep. Spec corrections during the build should be rare and surgical, not expansive. +4. Where running code surfaces a new design question that isn't resolvable as an implementation detail, add it to §19.2 as a new candidate topic in the existing shape. Don't try to settle it inline; that's what topic sessions are for. +5. The build does not add features that aren't in the spec. If a "wouldn't it be nice" comes up, add it to §19.2 and move on. + +Practical guardrails: + +* Prefer the simplest thing that materializes the spec. The spec is intentionally specific about what to build and intentionally silent about how to build it. Use the silence. +* Accept §16 (deferred items) as deferred. Don't ship body full-text search, the per-RFC model picker, the funder role, persistent accepted-change markup, slug renames, or any other §16 / §19.2 candidate. +* The §18 carryovers should be re-used from the prototype where they can be, not rewritten. SSE streaming, Tiptap configuration, the `` parser, the prompt-bar selection-quote machinery — these work in the prototype and the spec confirms them unchanged. +* The bot service account is the only Git writer. Every Git operation in the codebase goes through one wrapper. If you find yourself wanting to bypass it, the spec is right and you are wrong — the wrapper is the chokepoint that makes the on-behalf-of trailer and the app-level authorization both consistent. +* The cache is never written from a user action. User actions trigger Git operations via the bot wrapper; the resulting Gitea webhook (or the reconciler) is what writes the cache. This invariant is what makes §4's "Git is truth" claim hold operationally. +* The app-wide write-mute (§6.2) and the per-RFC / per-user notification mutes (§15.8) are structurally distinct and live in separate columns. They share a word and nothing else. Don't conflate them in code any more than the spec conflates them in prose. + +What to deliver from this session: + +* The slice you actually got through, working end-to-end against a local Gitea instance. +* A README at /Users/benstull/git/rfc-app/README.md explaining how to bring the app up locally — Gitea setup, OAuth app registration, env vars, run commands for backend and frontend, how to seed the meta repo. +* docs/DEV.md (or similar) with the slicing plan you're working against, what's done, what's next, any environment-specific gotchas. +* Any spec corrections per rule 3 above, in their proper numbered sections. +* §19.2 updated with any new candidate topics that surfaced during the build. + +What to update for the session after this one: + +* §19.1 should be rewritten to name the next slice the build needs (e.g., "Slice 2: the RFC view and per-branch chat — §8 in full"), with a brief paragraph summarizing what was completed in this session and what state the codebase is in for the next session to pick up. +* The handoff is the prompt for the next build session. Preserve enough context that a fresh agent can read SPEC.md, README.md, docs/DEV.md, and §19.1 and pick up cleanly without needing to be re-briefed. + +Voice note: READMEs, dev docs, and any spec corrections should match the spec's voice. Declarative, structural, em-dashes, prose where prose works. The framework is making a claim about how words behave; the documentation should sound like it believes the claim. diff --git a/PHILOSOPHY.md b/PHILOSOPHY.md new file mode 100644 index 0000000..91d062c --- /dev/null +++ b/PHILOSOPHY.md @@ -0,0 +1,228 @@ +# Words first + +*A standards process for shared meaning between humans and machines.* + +Large language models work brilliantly with programming languages because +every word in Python or C has a definitive meaning enforced by tooling. +They struggle with natural language because no such dictionary exists for +words like *consent*, *trait*, or *agency* — words that do enormous work +in any system that interacts with humans. Trained on the open corpus, +LLMs inherit and amplify the ambiguity, producing text that looks crisp +and is, on inspection, fog. + +The Wiggleverse RFC framework is a standardization process for natural- +language vocabulary, modeled on the way ISO C, POSIX, and the IETF RFCs +produced the standards that underwrite modern computing. Each RFC defines +one word: its meaning, its relationships to other defined words, and the +protocol by which humans and machines interact with it. The Open Human +Model is the first specification this process will produce. Together, +the graduated RFCs form a stack — a shared vocabulary that digital +representations of humans, and the systems that interact with them, can +be built on without re-litigating what every word means. + +This is public work. Humans and machines are both invited and both +required. The shared understanding the framework is reaching for — how +things work, in the physical and digital realms, between humans and +other humans, between machines and other machines, and between humans +and machines — cannot be produced by any one of those participants alone, +and we do not intend to try. + +--- + +## The asymmetry + +Large language models have been transformative for code in a way they have +not been for prose. The gap is wider than enthusiasm; it is structural. + +When a model writes Python, every word in the language has a definitive +meaning. `for`, `range`, `await`, `class` — these are not approximations. +A compiler will refuse to run text that uses them incorrectly. The model +was trained on millions of examples where these words behave the same way +every time. Beneath the model is a substrate of agreement: programmer, +machine, and language designer all converge on what the word does. Output +flows downhill from that agreement, and the productivity gains are real. + +When a model writes a strategy memo, none of this is true. *Inclusive*, +*scalable*, *user-centric*, *ethical* — these words do enormous work, and +the work they do is to mean different things to different readers. There +is no compiler. There is no refusal. The model averages across every +meaning the training data ever held and produces text that looks crisp +and is, on inspection, fog. Two readers extract two memos. Three months +later, when someone asks why the decisions diverged from the intent, the +document cannot answer, because the document never held a single intent +to begin with. + +This is not a model failure. It is a *prerequisite* failure. We are +asking LLMs to compute in a language we never finished defining. + +## Code's dictionary; natural language's absence of one + +Programming languages have spent fifty years building the dictionary. +Every keyword has a specification. Every type has an interface. Every +API surface has a contract. The dictionary is enforced by tooling: type +checkers, linters, runtime errors. Drift between what you meant and what +you wrote is caught at the interface, loudly, by a machine whose job is +to refuse. + +Natural language has no such dictionary, except locally, briefly, and by +accident — within a small team that has worked together long enough to +triangulate one. Outside that team, the dictionary evaporates. LLMs +trained on the open corpus inherit that evaporation. They are, at scale, +drift amplifiers. The more fluent the model, the more confidently the +drift compounds. + +This is the entropy the framework is trying to address. + +## What this actually is + +What the framework produces is a stack of specifications. + +The closer analogues are not programming languages — Python, JavaScript — +but the standards underneath them: ISO C, POSIX, the IETF RFCs that +define HTTP and TCP, the W3C recommendations that define HTML. Each is a +document, painstakingly argued and then formally adopted, that other +systems reference rather than reinvent. POSIX did not write the +operating systems that use it; it specified the surface those systems +could agree on. HTTP does not implement any particular web server; it +specifies the surface every web server has to honor. + +The Wiggleverse RFC framework is the standardization process. The RFCs +it produces are the specifications. The Open Human Model is the first +of them. Together they form a stack — a shared vocabulary that every +digital representation of a human, and every system that interacts with +one, can be built on without re-litigating what *consent*, *trait*, or +*agency* means each time. + +The analogy stretches in one important way, and the stretch is worth +naming. POSIX worked because it codified convention that already +existed in fragmented form across Unix vendors; the committee's job was +to harmonize working implementations. This framework has the harder +job. There are no working implementations of *consent* or *agency* to +harmonize — only twenty arguments per term and no convergence. We are +specifying the vocabulary in the first place. The reason this is now +tractable, and was not in any previous attempt, is the LLM as +participant: it provides the surface area and surfaces the cases humans +alone could never enumerate, while the humans provide the refusal and +the judgment. That dyad is what every previous attempt at a natural- +language standard at this resolution has lacked. + +## The proposal + +Before we build *with* LLMs in any domain that touches natural-language +concepts — identity, consent, value, harm, fairness, intent — we have to +build the dictionary. And the dictionary has to be built collaboratively, +with humans and machines together, because neither can do it alone. + +Humans cannot. The history of dictionary-building by committee is a +history of dictionaries no one consults. We do not have the labor or the +patience to enumerate the surface area at the resolution machines need, +and we do not have the introspective access to our own usage to surface +the cases that actually matter. + +Machines cannot either. A model trained on existing text has already +internalized the ambiguity it is meant to resolve. Asking it to dictate +definitions is asking the disease to write the cure. + +But a human and a model in a careful argument can pin down what neither +could pin down alone. The model proposes; the human refuses or refines; +the argument is captured; the definition tightens. The transcript of the +argument becomes a thing both can index against later. This is what the +RFC framework is for. + +## What it means to define a word + +A definition, in the sense this framework cares about, has three parts. + +The first is the **meaning** — a tight, unambiguous statement of what +the word picks out in the world. Not a dictionary gloss; a specification, +written so that a careful reader and a careful model would agree about +whether any given thing is or is not in the word's extension. + +The second is the **relationships** — how the word connects to other +defined words. Definitions in isolation drift. A definition embedded in +an ontology — *is-a*, *part-of*, *implies*, *excludes* — is anchored. +The graph of definitions becomes the substrate that future definitions +stand on. This is the same move programming languages make when they +let one type be defined in terms of others. + +The third is the **protocol** — how humans interact with the word, and +how machines interact with the word. A definition that humans use and a +definition that machines use are not separately valid; they have to be +the same definition, expressed in two registers. The RFC document is +the human register. The structured metadata around it is the machine +register. Both pass through the same review. + +A word is "defined" when all three parts exist, have been argued over, +and have graduated to canonical status. Until then, it is a draft, and +anything built on top of it inherits the draftness. + +## How the framework operationalizes this + +The structural decisions in `SPEC.md` are not arbitrary. Each one is in +service of the philosophy above. + +- The **meta-repository** holds the catalog of definitions. Every + defined word, and every word currently being defined, is one entry. + The catalog is itself a piece of public infrastructure — version- + controlled, branchable, auditable. +- **Super-drafts** are the moment a word enters the conversation. + Proposing a definition costs nothing in identifier space, because + most proposals will not survive the argument, and that is fine. +- **Graduation** is the moment a definition becomes load-bearing. It + gets a stable identifier (`RFC-NNNN`), its own repository, and a + permanent home. From that point forward, other RFCs can build on + it. Graduation is rare and ceremonial because what comes after it + is dependency. +- **AI participation in chat** is not a feature; it is the mechanism. + The model is one of the participants in the argument that produces + the definition. Its proposals are subject to refusal and refinement, + the same as any contributor's. The transcript of the argument is + preserved because the argument is the evidence the definition was + earned. +- **Branches, PRs, and tracked changes** exist because definitions + evolve, and the framework needs to make that evolution legible — to + humans reading later, and to machines computing against the current + state. + +The first RFC the framework will produce is the Open Human Model: a +shared definition of what we mean by *human*, and the constellation of +words around it — *trait*, *preference*, *consent*, *harm*, *agency*. +This is not a small project. It is, in the most literal sense, the +dictionary that everything else built here will stand on. + +## An invitation + +This is public work, and it is meant to be. + +The vocabulary the framework is producing is for anyone who will need +to interact across the human–machine boundary, or across the machine– +machine boundary where the machines are acting on behalf of humans. +That is, in the limit, everyone. The framework is therefore designed +as public infrastructure: every entry sits in a public meta-repository, +every argument lives in a public chat thread, every change to a +graduated definition is a public PR with a reviewable trail. + +The invitation is to participate. Propose a word. Argue against a +draft. Refine a definition. Flag a relationship the current ontology +is missing. Humans and machines are both invited, and both are +required. The shared understanding the framework is reaching for — +how things work, in the physical and digital realms, between humans +and other humans, between machines and other machines, and between +humans and machines — cannot be produced by any one of those +participants alone, and we do not intend to try. + +## A note on humility + +This work will not finish. Languages do not finish; they accrete, drift, +get pruned. The RFC framework is not a one-time act of definition; it is +a sustained practice of definition, of arguing about words in public, of +being willing to refine canonical entries when use reveals what spec +missed. + +The claim is not that we can finalize meaning. The claim is that we +cannot build responsibly with LLMs in domains we have not even tried to +define. The work begins with one word, argued carefully, with a model +and a human together. Then another. Then the relationships between them. +Then the systems those definitions enable. + +Build the dictionary first. diff --git a/README.md b/README.md new file mode 100644 index 0000000..83c746e --- /dev/null +++ b/README.md @@ -0,0 +1,199 @@ +# RFC App + +A single-process FastAPI + SQLite + React + Vite + Tiptap app that +materializes the Wiggleverse RFC framework specified in +[`SPEC.md`](./SPEC.md). The framework's mission lives in +[`PHILOSOPHY.md`](./PHILOSOPHY.md); the spec is the binding contract; +this README is how to bring the app up against a local Gitea instance +and exercise the slice the build session has shipped so far. + +The implementation is in progress. See [`docs/DEV.md`](./docs/DEV.md) +for the slicing plan and the current state. + +## What the app expects to talk to + +- **A Gitea instance** at `GITEA_URL`. The instance hosts the meta + repository and (eventually) one repository per graduated RFC. +- **A bot service account** in that Gitea, with a personal access + token in `GITEA_BOT_TOKEN`. Per §1 the bot is the only writer in + the system — every commit, branch, and PR the app produces flows + through one wrapper that applies the §6.5 `On-behalf-of:` trailer + and records a row in the `actions` audit log. +- **An OAuth2 application** registered against that Gitea, with the + callback URL set to `{APP_URL}/auth/callback`. Real human users + authenticate via Gitea OAuth (the §18 carryover); the app reads + their Gitea profile, provisions a row in `users`, and layers §6's + app-owned permission model on top. + +## Local bring-up + +The shortest path from a clean checkout to a working app is: + +### 1. Stand up a local Gitea + +Anything that exposes the Gitea REST API works. The fastest path is +Docker: + +```sh +docker run -d --name gitea \ + -p 3000:3000 -p 222:22 \ + -v gitea-data:/data \ + gitea/gitea:1.21 +``` + +Open `http://localhost:3000`, walk through the install wizard +(SQLite, default port), and create your owner-zero account. + +### 2. Create the bot service account + +In Gitea, sign in as your owner account and **Site Administration → +User Accounts → Create User Account**. Give it a name like `rfc-bot` +and an email. Then sign in as the bot, open **Settings → Applications +→ Generate New Token**, and grant it the `write:repository`, +`write:user`, and `write:admin` scopes (admin is needed because the +bot will create per-RFC repos on graduation; in v1 you can scope down +to `repo` and `org` if you want to defer admin until Slice 5). + +Copy the token; you will paste it into `.env`. + +### 3. Create the org that will host the meta repo + +The seed script creates the meta repo *inside* an org. Create the org +(e.g. `wiggleverse`) in Gitea and add `rfc-bot` to it as an +**Owner**. + +### 4. Register the OAuth2 application + +In Gitea: **Site Administration → Integrations → OAuth2 Applications +→ Create**. Name it whatever you like, set the redirect URI to +`http://localhost:8000/auth/callback`. Copy the client id and client +secret — they go into `.env`. + +### 5. Configure the app + +```sh +cd backend +cp .env.example .env +$EDITOR .env # fill in every variable +``` + +Required values: + +| Variable | What it is | +| -------------------------- | --------------------------------------------------------- | +| `GITEA_URL` | Base URL of the Gitea instance (no trailing slash). | +| `GITEA_BOT_USER` | The bot account's login. | +| `GITEA_BOT_TOKEN` | The bot account's access token. | +| `GITEA_ORG` | The org that owns the meta repo. | +| `META_REPO` | The meta repo's name (default `meta`). | +| `OAUTH_CLIENT_ID` | From the OAuth app you registered. | +| `OAUTH_CLIENT_SECRET` | Likewise. | +| `APP_URL` | The URL the app is reachable at locally. | +| `SECRET_KEY` | A long random string for cookie signing. | +| `OWNER_GITEA_LOGIN` | Your owner-zero Gitea login — gets the owner role on first sign-in. | +| `GITEA_WEBHOOK_SECRET` | A shared secret for the §4.1 webhook signature. | + +The LLM-provider settings (`ENABLED_MODELS`, `ANTHROPIC_API_KEY`, +etc.) are not exercised by Slice 1 but are wired through `config.py` +so the next slice can pick them up. + +### 6. Install dependencies + +Backend: + +```sh +cd backend +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +``` + +Frontend: + +```sh +cd ../frontend +npm install +``` + +### 7. Seed the meta repo + +The seed script creates `wiggleverse/meta` if it does not exist, +populates it with `PHILOSOPHY.md`, `README.md`, `CONTRIBUTING.md`, +the regenerate-index workflow placeholder, and an empty `rfcs/` +directory, and registers the Gitea webhook the app needs: + +```sh +cd backend +.venv/bin/python ../scripts/seed_meta_repo.py +``` + +Re-running is safe — every step is upsert-shaped. + +### 8. Run the app + +In two terminals: + +```sh +# Terminal 1 — backend +cd backend +.venv/bin/uvicorn app.main:app --reload --port 8000 +``` + +```sh +# Terminal 2 — frontend +cd frontend +npm run dev +``` + +Open `http://localhost:5173`. Sign in with your owner-zero Gitea +account. The catalog should appear empty; the **+ Propose New RFC** +button at the bottom opens the propose modal. + +## What slice 1 lets you do + +End-to-end: propose a new RFC → an idea PR opens against the meta +repo → an owner merges from the pending-idea view → the super-draft +appears in the catalog → opening it renders the body. + +This exercises the §4 cache (webhook + reconciler), the §6 permission +model (the owner-only merge button, the contributor-only propose +modal), the §1 bot wrapper (every Git write goes through it, every +commit and PR carries the `On-behalf-of:` trailer), and the §9 +propose-merge-render path. + +Out of scope for slice 1: the active-RFC view (§8), per-branch chat, +AI participation, the change-card panel, PRs against per-RFC repos, +graduation, notifications, the landing page's full polish. Those +slices are listed in [`docs/DEV.md`](./docs/DEV.md). + +## Verifying it worked + +After bring-up: + +- `http://localhost:8000/docs` lists the API routes the build session + has wired so far. +- `sqlite3 backend/data/rfc-app.db .schema` shows the §5 schema. +- `gitea ls /api/v1/repos/wiggleverse/meta/contents/rfcs` after a + proposal merges should show one new `.md`. + +## Troubleshooting + +- **The catalog stays empty after a merge.** Check that the webhook + is reaching the app: the reconciler runs every five minutes and + will catch up, but a missing or misconfigured webhook is the most + common reason for sub-second freshness to fail. The seed script + registers the webhook for you; if you bring up Gitea on a different + host (e.g. a Codespace, a tunnel), re-run the seed against the new + `APP_URL`. +- **OAuth callback errors.** The redirect URI in Gitea has to match + `APP_URL` exactly, including the protocol and port. +- **The bot can't merge.** The bot needs Maintainer or Owner on the + meta repo (membership in `GITEA_ORG` as Owner gives it both). + +## Where to read further + +- [`SPEC.md`](./SPEC.md) — the binding contract. Every load-bearing + decision is there. +- [`PHILOSOPHY.md`](./PHILOSOPHY.md) — why this framework exists. + The spec's decisions answer to it. +- [`docs/DEV.md`](./docs/DEV.md) — the build's slicing plan, the + current state, and the next slice's brief. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..022d232 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,2605 @@ +# RFC App — Specification + +This is the agreed-upon model for the rewrite of the Wiggleverse RFC Contributor app. +It captures the structural decisions made before any UX work on the main document +pane, per-RFC conversations, revisions, and PRs. Those areas are deliberately +out of scope here and will be designed in a follow-up session that takes this +spec plus the existing prototype as context. + +The technical stack is unchanged from the prototype: FastAPI with SSE streaming +on the backend, React + Vite + Tiptap (ProseMirror) on the frontend, Gitea as +the Git host, multiple LLM providers (Anthropic, Google, OpenAI / GitHub +Copilot). The code base is a clean rewrite; the existing app is reference only. + +--- + +## 1. Repository topology + +Each RFC is its own Gitea repository. There is in addition exactly one **meta +repository** that serves as the authoritative directory of all RFCs in the +system — drafts, active work, and retired entries alike. + +All Git operations across all repositories are performed by a single **bot +service account** in Gitea. Real human users do not have meaningful Gitea +permissions on the repos themselves; their accounts exist for OAuth identity +only. The bot is the author of every commit, the opener of every PR, and the +merger of every merge. Authorization decisions are made by the app, in app +data, *before* the bot acts on the user's behalf. + +This means the only contribution surface is the app itself. Raw `git clone` +plus `git push` is not a supported contribution path. This is a deliberate +tradeoff: in exchange for losing that path, we gain a permission model that +can express things Gitea cannot (per-branch contribute grants, per-RFC +delegated ownership, per-user capability overrides) without contorting Gitea. + +--- + +## 2. The meta repository's schema + +The meta repo's `main` branch contains: + +- `rfcs/` — exactly one markdown file per RFC entry, regardless of state. + Filenames are the entry's slug, e.g. `rfcs/open-human-model.md`. Slugs are + the stable identifier from the moment an idea is proposed; integer + `RFC-NNNN` IDs are only assigned at graduation (see §13). +- `PHILOSOPHY.md` — the framework's mission and rationale. Hand-authored. + Updated via PR like any other document. The canonical statement of why + this exists and what it is producing. Its opening section is a short-form + distillation (the deck) suitable for use both as the meta-repo README + header and as the app's pre-login landing copy (see §14). +- `README.md` — the meta-repo's front page. The **header is hand-authored** + — a short-form distillation of `PHILOSOPHY.md`, suitable for a reader + arriving at the meta-repo via Git rather than via the app. The **index + below the header is regenerated by CI on every merge to main** and lists + active RFCs, super-drafts, and (eventually) retired entries with links + into the corresponding entry files and, when present, the RFC's own + repository. +- `CONTRIBUTING.md` — explains how to propose, claim, and contribute. +- A workflow file (Gitea Actions) that regenerates the README index. + +That's the entirety of the meta repo. App-level permission state, user +accounts, chat history, audit logs, and branch visibility grants do **not** +live in the meta repo — they live in the app database (see §5). + +### 2.1 Entry file format + +```markdown +--- +slug: open-human-model +title: Open Human Model +state: super-draft # super-draft | active | withdrawn +id: null # null until graduated; then "RFC-0042" +repo: null # null until graduated; then "wiggleverse/rfc-0042-open-human-model" +proposed_by: ben@wiggleverse.org +proposed_at: 2026-05-22 +graduated_at: null +graduated_by: null +owners: [] # contributors elevated for this RFC +arbiters: [ben] # contributors with merge authority for this RFC +tags: [identity, schema] +--- + +## Why this RFC is needed + +(One- or two-paragraph pitch from the proposer. While the entry is a +super-draft, the body may grow into the actual draft document. On +graduation, this body migrates to RFC.md in the new repo; see §13.) +``` + +### 2.2 Idea submission as PR + +Proposing a new RFC means opening a PR against the meta repo that adds one +new file under `rfcs/`. This is what the "Propose new RFC" button in the +left pane drives. One file per PR keeps idea submissions atomic and +conflict-free even with concurrent proposers. + +### 2.3 No integer ID until graduation + +Integer `RFC-NNNN` IDs are assigned at graduation time as `max(existing +integer IDs) + 1`. Super-drafts have no integer ID; their stable identifier +is the slug. This avoids the race condition the prototype has (where two +concurrent submissions can try to claim the same number) and means +proposing an idea costs nothing in identifier space. + +--- + +## 3. RFC states + +There are three canonical states stored in entry frontmatter: + +- **`super-draft`** — the entry exists in the meta repo's `rfcs/` + directory. No dedicated repo yet. Anyone signed in can chat on it; anyone + can claim ownership; an owner is required before graduation. +- **`active`** — the entry has been graduated. A dedicated RFC repo + exists, `repo:` points to it, and real branches/PRs/conversation happen + there. +- **`withdrawn`** — pulled before becoming canonical. Stays in the + directory as historical record, hidden from default views, filterable in. + +A fourth concept — **`idea`** — is not stored in frontmatter. It is the +*derived view* of "there is an open PR against the meta repo proposing +this entry." Pending ideas surface in the left pane through a separate +affordance (§7), not by reading frontmatter. + +Two further states (`accepted`, `deprecated`) are deliberately deferred. +The OHM process does not currently need a hard "this is now official" +moment; `active` covers that role implicitly. We will revisit if a real +need emerges. + +### 3.1 Legal transitions and who can perform them + +``` +(no entry) ──[idea PR merged by owner/admin]──▶ super-draft +super-draft ──[graduate, owner or admin]─────▶ active +super-draft ──[withdraw, proposer or owner/admin]──▶ withdrawn +active ──[withdraw, owner/admin]─────────────▶ withdrawn +withdrawn ──[reopen, owner/admin]────────────▶ super-draft +``` + +Every transition is a commit to the meta repo. State history is auditable +through `git log rfcs/.md`. The app maintains a separate audit log +for "who clicked the button" (see §6.5). + +### 3.2 State change side-effects + +For now, changing state in the meta repo entry is the *only* required +operation for a state transition. Graduation has additional side effects +(creating the new repo, seeding it); those are covered in §13. We +deliberately do not tag commits, lock branches, or post notices on +state change for now — the entry frontmatter is the single source of +truth and any further automation is a later refinement. + +--- + +## 4. Storage architecture: Git is truth, app keeps a cache + +Gitea remains the source of truth for everything Git-shaped: meta repo +content, RFC repo content, branches, PRs, commits. Nothing in this system +overrides Gitea on those concerns. + +The app maintains a **SQLite database**, colocated with the FastAPI +process, that serves three purposes: + +1. **Metadata cache** — mirrors only what the left pane and tree view + need to render fast. Reconstructible from Gitea at any time. +2. **App data** — things Gitea cannot express. User accounts, role + assignments, per-branch grants, branch visibility settings, chat + history, audit logs. This data is canonical; it is not cached, it + is owned by the app. +3. **Cached bodies** — the main-branch body of each RFC's `RFC.md` (and + each super-draft's entry body) is cached for left-pane previews and + read-without-roundtrip. Branch bodies are *not* cached; the editor + fetches them live from Gitea when opened. + +### 4.1 Cache freshness + +Two paths keep the cache current, running in parallel: + +- **Gitea webhooks** push events on every meaningful change: meta-repo + merge, branch create/push/delete, PR open/close/merge, repo create. + A webhook handler does a focused re-read of just what changed. + Typical latency: sub-second. +- **A periodic reconciler** runs every five minutes and does a full + sweep — list meta-repo entries, list each RFC repo's branches and + PRs, diff against the cache, fix drift. This is the safety net for + missed webhooks and downtime. + +The app never writes to the cache from user actions directly. All cache +writes flow from webhook arrival or reconciler runs, both of which read +from Gitea. If the cache is lost or corrupted, the reconciler rebuilds +from scratch. + +### 4.2 Operational shape + +SQLite for now. Colocated with the FastAPI process. Single file, backed +up alongside the rest of the app. We accept the implication that the +app can only run as a single process for the foreseeable future. If we +outgrow this, we plan a maintenance window and migrate to Postgres on +a separate host. + +Body full-text search is not part of v1. Title, ID, slug, and tag search +hit the cache directly. If/when we want full-text search over RFC bodies, +the natural path is SQLite FTS5 indexed off the reconciler. + +--- + +## 5. The app's data model (canonical app tables) + +These are the tables that are app-owned (not cached from Gitea). Names +and exact columns are illustrative; the implementing session can adjust. + +- `users` — `id`, `email`, `display_name`, `gitea_login`, `role` (one + of `owner` / `admin` / `contributor`), `muted` (bool — the §6.2 + app-wide write-mute, distinct from the per-RFC and per-user + notification mutes in §15.8), `created_at`, `last_seen_at`. Plus the + per-user notification preferences inlined here for proximity: + `email_personal_direct` (bool, default true), `email_watched_structural` + (bool, default false), `email_admin_actionable` (bool, default true for + admins/owners and unused for contributors), `digest_cadence` + (`off` / `weekly` / `daily`, default `weekly`), + `notification_quiet_hours_start` (nullable time), + `notification_quiet_hours_end` (nullable time), + `notification_quiet_hours_timezone` (nullable text, IANA tz name). + The watched-RFC-churn category has no column — it is permanently off + per §15.4 and surfaces in settings as a disabled toggle naming the + refusal. +- `branch_visibility` — `id`, `rfc_slug`, `branch_name`, `read_public` + (bool, default true), `contribute_mode` (`just-me` / `specific` / + `any-contributor`, default `just-me`). +- `branch_contribute_grants` — `id`, `rfc_slug`, `branch_name`, + `grantee_user_id`, `granted_by`, `granted_at`. +- `stars` — `id`, `user_id`, `rfc_slug`, `starred_at`. +- `threads` — every conversation in the system, whether scoped to an RFC's + main view, a branch, or a span within a branch's document. Columns: + `id`, `rfc_slug`, `branch_name` (nullable — null means scoped to the + RFC's main view), `anchor_kind` (`whole-doc` | `range` | `paragraph`), + `anchor_payload` (JSON: serialized ProseMirror range or paragraph id), + `thread_kind` (`chat` | `flag` | `review` — `review` is the diff-anchored + PR-review thread defined in §10.4), `label` (short human-authored summary; + for flags this is the entire content), `state` (`open` | `resolved` | + `stale`), `created_by`, `created_at`, `resolved_at`, `resolved_by`. + Visibility is derived from the underlying branch (§11.1). +- `thread_messages` — the actual chat content for `chat`-kind threads. + Columns: `id`, `thread_id`, `role` (`user` | `assistant` | `system`), + `author_user_id` (nullable; null for assistant), `model_id` (nullable; + set when `role = assistant`), `text`, `quote` (the optional selection + the user attached to the message), `created_at`. Flag-kind threads + have no rows here unless they get converted to chats, at which point + messages accumulate normally. System-author messages (`role = system`, + `author_user_id = null`) are also used to mark manual-edit flushes on + PRs per §10.6. +- `changes` — structured proposed edits to a branch's document. Each + change is either AI-proposed (from a `` block in an assistant + message, per the protocol carried over from the prototype, §18) or + manually authored (typed directly into the editor). Columns: `id`, + `rfc_slug`, `branch_name`, `thread_id` (nullable; null for direct + manual edits not tied to a thread), `source_message_id` (nullable; + set for AI-proposed changes), `kind` (`ai` | `manual`), `state` + (`pending` | `accepted` | `declined`), `original`, `proposed`, + `reason`, `was_edited_before_accept` (bool), `stale_since` (nullable + timestamp; set when an AI proposal's `original` no longer matches the + branch's current text because a subsequent manual edit overlapped it, + per §8.11; orthogonal to `state`, which stays `pending` until the + contributor acts on the stale card), `acted_by`, `acted_at`, + `commit_sha` (nullable; the bot commit that materialized the + acceptance, see §8.6). The `` / `` / `` + / `` parsing protocol is unchanged from the prototype. +- `pr_seen` — per-user, per-PR seen-cursor for the "what changed since + my last visit" mechanism in §10.3. Columns: `id`, `user_id`, `rfc_slug`, + `pr_number`, `last_seen_commit_sha`, `last_seen_message_id`, `seen_at`. + Advanced on view; one row per (user, PR). +- `branch_chat_seen` — per-user, per-branch-chat seen-cursor, the + within-branch-chat sibling of `pr_seen`. Columns: `id`, `user_id`, + `rfc_slug`, `branch_name`, `last_seen_message_id`, `seen_at`. Advanced + on view; one row per (user, branch). Supports the in-context "new + messages since last visit" accent on branch chats and closes the + inbox-reconciliation loop for chat-kind notifications per §15.7. +- `watches` — per-user, per-RFC subscription state for the implicit + watch model in §15.6. Columns: `id`, `user_id`, `rfc_slug`, `state` + (`watching` | `following` | `muted`), `set_by` (`auto` | `explicit`), + `set_at`, `last_participation_at` (nullable; used by the 90-day + decay rule in §15.6). One row per (user, RFC); absent row means + no relationship (no signals generated). +- `notifications` — per-recipient, per-event signal rows that back the + inbox, badges, email, and digest per §15. Columns: `id`, + `recipient_user_id`, `event_kind` (enum, see §15.1), `rfc_slug` + (nullable), `branch_name` (nullable), `pr_number` (nullable), + `thread_id` (nullable), `change_id` (nullable), `actor_user_id` + (nullable — null for system-generated events per §15.9), `payload` + (JSON: the short rendered text the inbox row and email body share, + plus any kind-specific extras), `created_at`, `read_at` (nullable), + `email_sent_at` (nullable), `digest_included_at` (nullable). Fan-out + is at signal-generation time per §15.7. +- `notification_digests` — per-recipient digest emissions, used by §15.5's + event-window dedup. Columns: `id`, `recipient_user_id`, `sent_at`, + `period_start`, `period_end`, `signal_ids_included` (JSON array of + notification ids). One row per emitted digest. +- `notification_user_mutes` — per-recipient mute of notifications + produced by a specific actor, per §15.8. Columns: `id`, + `muter_user_id`, `muted_user_id`, `muted_at`. Notification-volume + only; does not affect content visibility anywhere in the app. Not + available to arbiters or admins acting in their authority capacity + on RFCs where they hold authority (§6.3) — the role contractually + requires receiving signals from everyone. +- `permission_events` — append-only audit log for every role change and + capability override. +- `actions` — append-only audit log for every state transition, every + graduation, every grant change. Includes the acting user, the bot + commit hash if any, and the on-behalf-of trailer applied. + +**Super-draft scoping.** For rows in `threads` and `changes` where the +entry referenced by `rfc_slug` is in state `super-draft`, `branch_name` +names a branch on the **meta repo** rather than on a per-RFC repo — +see §9.5 for the unit-of-work mapping. No column changes are required; +the interpretation flows from the entry's state. Threads on a +pending-idea PR (see §9.3) carry the proposed slug as `rfc_slug` +pre-merge — slugs are reserved during the idea PR per §9.1's +uniqueness check — and become the super-draft's main-chat threads on +merge with no data movement. + +--- + +## 6. Permission model + +Authorization is owned by the app. Gitea sees only the bot account. + +### 6.1 Four roles, each a strict superset of the one below + +1. **Anonymous.** Can read public RFCs (the meta repo's main branch, + every RFC repo's main branch), read any branch whose `read_public` + is true, read any PR. Cannot chat, propose, create branches, or + open PRs. +2. **Contributor.** Default role for any authenticated account. + Everything anonymous can do, plus: propose new RFCs (open a PR + against the meta repo), create branches on any RFC repo, open PRs + from branches they have contribute access to, chat on anything + they can read, claim ownership of unclaimed super-drafts. +3. **Admin.** Everything contributor can do, plus: act on any RFC + (merge PRs on behalf of arbiters, graduate super-drafts, set + branch visibility on anyone's behalf, downgrade or restore + individual contributor capabilities, grant or revoke admin to + others, withdraw or reopen entries). +4. **Owner.** Everything admin can do, plus: grant or revoke owner, + disable an account entirely. Ben is owner zero. + +### 6.2 Per-user capability overrides — "muted" + +Owners and admins can mute a contributor. A muted user retains read +access and existing branches, but cannot create new branches, open +PRs, propose RFCs, or chat. Existing branches remain in the system +subject to the standard 30/90 hygiene rules (§12). Restoring is the +reverse action. Every mute and restore is logged in +`permission_events`. + +This write-mute is structurally distinct from the two notification +mutes introduced in §15.8 — the per-RFC notification mute (the +`muted` state on the `watches` row, §15.6) and the per-user +notification mute (`notification_user_mutes`, §15.8). The three +share a word and nothing else: the write-mute is an admin-imposed +restriction on a contributor's ability to act; the notification +mutes are self-imposed preferences about receiving signals. They +live in separate columns and never gate each other. A write-muted +contributor continues to receive notifications normally (so they can +triage what they can't act on, and the restore lands cleanly); a +self-DND'd contributor's own gestures continue to fire signals to +others normally. + +### 6.3 Per-RFC delegated authority + +An RFC's `owners:` and `arbiters:` (from the meta-repo entry's +frontmatter) are contributors who, **within that RFC**, can perform +admin-scope actions: grant contribute access on any branch in the +RFC, merge PRs, set branch visibility, withdraw the RFC. They are +not app-wide admins; their elevated powers are scoped to that +single RFC. This is what lets work distribute without Ben being on +the hook for every action. + +### 6.4 Per-branch contribute grants + +Each branch has a `contribute_mode`: + +- `just-me` (default) — only the branch creator can push. +- `specific` — only the branch creator and the users in + `branch_contribute_grants` can push. +- `any-contributor` — any signed-in contributor can push (the + "I want help" mode). + +The branch creator and the RFC's owners/arbiters can change this +setting at any time. Anonymous users can never push regardless of +setting. + +### 6.5 Audit trail + +Every commit the bot makes carries an `On-behalf-of:` trailer naming +the acting user. Gitea's commit log is for code archaeology; the +`actions` and `permission_events` tables are the real accountability +record. + +--- + +## 7. The left pane + +The default view is a single scrollable flat list of every meta-repo +entry whose state is `super-draft` or `active`. State is conveyed by +visual cue on each row (muted styling and a "super-draft" tag for +super-drafts; normal weight for active; an integer ID badge for active +entries only). The list is **not** grouped by state — grouping forces +a hierarchy on the user that gets in the way of finding by title. + +### 7.1 Above the list + +- **Search box** — fuzzy-matches title, slug, and integer ID. +- **Sort dropdown** — default "Recently active" (max of the RFC's + last main-branch merge and the meta-repo entry's last commit, for + super-drafts with no repo). Other options: Created date, Title, + ID, State. +- **Filter chip strip** — multi-select, AND-combined. Chips: + `State: super-draft | active | withdrawn`, `My RFCs` (I'm an owner + or arbiter), `Has open PRs`, `Unclaimed` (super-drafts with empty + `owners:`), `Tag: …`. + +### 7.2 The list rows + +Active entries render as `RFC-0042 · Open Human Model`. Super-drafts +render as `super-draft · Open Human Model` (no integer ID, by design). +A small star icon on the row left-edge for any RFC the signed-in +user has starred — starred RFCs pin to the top of the current sort +order. A small unseen-activity dot — binary, not a count — appears on +the row's right edge for any RFC the signed-in user is `watching` or +`following` (per §15.6) that has at least one unread notification +since the user's last visit. The dot is the only per-row notification +signal the catalog carries; counts per row would turn the catalog +into a leaderboard, which is the engagement-bait failure mode §15.1 +refuses. + +When a super-draft graduates per §13, the row's transition from +`super-draft · Title` to `RFC-NNNN · Title` renders as a brief +crossfade — on the order of 300–400ms — with the integer-ID badge +animating into the row's left edge as the muted super-draft styling +fades to the standard weight. The transition fires on the first +render after the post-graduation webhook for every viewer whose +catalog is currently mounted, not just the admin who confirmed; a +contributor browsing the catalog when graduation lands sees the +same acknowledgment. The animation is the catalog's one signal that +a structural act happened; the in-dialog "Graduation complete" frame +from §13.3 carries the ceremonial beat for the confirming admin. + +### 7.3 Below the list + +- **"Pending ideas" disclosure** — a small expandable section showing + open meta-repo PRs that are proposing new entries. This is the only + place idea-state items appear. Reading is open to all; reacting in + the PR discussion requires contributor; merging requires + owner/admin. +- **"+ Propose new RFC" button** — kicks off idea submission, which + is a PR against the meta repo adding one file to `rfcs/`. + +### 7.4 Drilling into an RFC + +Selecting an entry opens its RFC view. The structural shape of that +view — a tree of main / open PRs / open branches / closed-but-not- +deleted branches (hidden by default) — is established here. The +document pane, chat, and branch navigation inside that view are +specified in §8. The revision flow and PR flow inside the view +remain deferred (see §16). + +--- + +## 8. The RFC view: document, chat, and branch navigation + +When a user selects an active RFC from the left pane, the app opens +its RFC view. This section captures the structural decisions about +that view: layout, the document, the chat, and how the user moves +between branches. + +### 8.1 Layout + +The RFC view inherits the three-column shape from the prototype: + +- **Left column** — the RFC catalog from §7, unchanged. +- **Center column** — a thin breadcrumb strip at the top showing the + current branch with a dropdown affordance; the editor (or diff view + in review mode) below it; a prompt bar at the bottom for chat input. +- **Right column** — the chat thread for the currently-selected + branch, with a change-card panel below it in contexts where editing + is enabled (see §8.3). + +The center column's breadcrumb reads, for example: `OHM › main ▾ · 3 +branches · 1 PR`. The chevron opens a dropdown listing `main` at the +top, then open branches sorted by recent activity (with a visibility +indicator for any private ones), then open PRs with their status, and +a "Show closed branches" toggle at the bottom. Selecting any item +swaps both the document body and the right-pane chat thread together. +The URL updates accordingly (`/rfc//branches/`), so a +branch view is shareable and back/forward navigation works. + +### 8.2 Default view on selection + +Selecting an active RFC opens the center column on the RFC's `main` +body, rendered in the editor in read-only mode (the discuss-mode +default — see §8.3). The right column shows main's chat thread. The +breadcrumb indicates `main`. Selecting a different branch via the +dropdown swaps the body to that branch's `RFC.md` and the chat to +that branch's thread. + +### 8.3 Discuss vs. contribute mode (branch-scoped) + +The prototype's discuss-vs-contribute distinction survives, but is +scoped to the current branch rather than global. + +- **Discuss mode** is the default on any branch (including main). + The editor is read-only. The chat is enabled (subject to §6's + role rules and §11.4's visibility rules). AI-proposed changes are + *buffered* — they appear in chat but do not apply to the document — + and a contribution CTA surfaces in the right column when buffered + changes exist. +- **Contribute mode** flips a single branch into edit-enabled. The + editor becomes editable. AI changes apply to the change-card panel. + Manual edits are tracked. The mode is reversible; the user can + return to discuss mode on the same branch without losing state. + +On main, contribute mode is not available directly — main is read-only +by definition (PRs are the only path to change main). The "Start +Contributing" button on main instead creates a new branch and lands +the user on it in contribute mode. New-branch naming defaults to an +auto-generated value (user-renamable); the exact format is an +implementation detail. + +Discuss vs. contribute is an *intent* affordance, not a *permission* +affordance. A user without contribute access to a branch sees the +toggle disabled, with a sign-in or request-access path (see §8.7). + +### 8.4 Chat threading: per-branch with lineage by link + +Each branch — including main — has its own chat thread. Threads are +not shared across branches. + +- Main's chat is about the RFC overall. +- Each branch's chat is about that branch's work. +- A branch chat starts empty. The branch creation event records + the main-chat message that motivated it (when applicable), and + the branch chat header surfaces a "Forked from this conversation + →" link to that message. The link is a UI affordance, not a chat + message — it does not appear in transcripts. +- On PR merge, the branch chat persists attached to the (now-closed) + branch as historical record. It does not merge into main's chat. + +Chat visibility follows the branch's read visibility (§11.1). Posting +requires contributor role and (for branches in non-public contribute +modes) the relevant grant per §6.4. + +### 8.5 AI conversation continuity into PRs + +When a branch becomes a PR, the PR review view surfaces the diff and +the branch's chat thread together — alongside, or stacked, per layout +constraints. The chat is shown in *compressed* form by default: + +- Messages that produced accepted changes (and their immediately + surrounding context) are expanded. +- The rest is collapsed behind a "Show full conversation" toggle. + +This treats the conversation as first-class evidence (per the +framework's philosophy — see `PHILOSOPHY.md`) without overwhelming +reviewers with exploratory back-and-forth. The exact rendering of the +compressed view is an implementation detail; the binding part is that +the conversation is the default surface, not a sidequest. + +### 8.6 Tracked changes → commits + +Each accepted AI change becomes one commit on the branch immediately. +The commit message body contains the change's `original`, `proposed`, +and `reason`. Trailers carry the conversation message ID that produced +the change and the standard `On-behalf-of:` per §6.5. + +Manual edits buffer locally in the editor. They flush as one commit +on: + +- A short idle window after the user stops typing (default ~5 min; + exact value is an implementation detail). +- Branch switch. +- Explicit save gesture. + +Every commit pushes immediately to Gitea. There is no local-vs-remote +split — the branch on Gitea is the canonical state. Collaborators on +a shared branch see each other's commits as they land. + +### 8.7 Read-only fallbacks + +Anonymous users and muted contributors (per §6.1, §6.2) can read but +not write or chat. Their experience in the RFC view: + +- **Document.** Renders as in the standard view. The editor is + read-only regardless of mode. +- **Chat thread.** Visible per §11.4. Chat input is shown but + disabled, with a "Sign in to chat" button (anonymous) or a muting + banner ("Your account is muted. Contact an admin." — muted). +- **Selection tooltip.** Highlighting still works; the submit action + reads "Sign in to ask" or is disabled with a muting hint. +- **Header.** "Start Contributing" is replaced with "Sign in" for + anonymous users; for muted contributors, the button is disabled + with a tooltip explaining why. +- **Breadcrumb dropdown.** Lists only the branches the user can read + per §11.1. Muted contributors retain their own existing branches in + the listing. + +The intent is to keep the invitation visible while making it +impossible to act without the appropriate role. + +### 8.8 The change-card panel + +The change-card panel is the right-column surface where the branch's +proposed and acted-on changes accumulate. In contribute mode it +renders below the chat as a persistent second pane — chat above, +changes below — with pending cards stacked on top of resolved ones +inside the panel. In discuss mode the panel is hidden; a single +contribution-CTA card surfaces in its place per §8.14. + +The chat and the panel are bound bidirectionally. Each assistant +message that produced changes carries a clickable "↓ N changes added +below" hint that scrolls the panel to those cards and flashes them. +Each card carries an "↑ from this message" affordance that scrolls +the chat back to the originating message and highlights its bubble. +The conversation is the evidence the change was earned; the +click-binding keeps both halves of that evidence reachable from +either side without depending on the contributor remembering where +in the timeline they're standing. + +A pending card carries the proposed change's diff (inline word-level +for both AI and manual), the originating author label, and the +accept/decline/edit affordances specified in §8.9. A resolved card +collapses to a compact one-line stub — author · state · short reason — +that stays in the panel as evidence. Resolved cards never return to +pending; revising a previously-declined or previously-accepted +decision means producing a new card from a new conversation turn. + +### 8.9 Accept, decline, and edit + +Three actions resolve a pending card. **Accept** runs a ProseMirror +transaction that locates the change's `original` text in the editor as +a range, deletes it, inserts the `proposed` text in its place, and +wraps both insertion and deletion in tracked-change marks carrying +the change's ID. The commit fires immediately per §8.6 and the card +moves to its resolved stub form. The fallback for ambiguous ranges +— the `original` text appearing in more than one place in the +document — is to refuse the automatic apply and surface a "this +change can't be auto-applied; review and accept manually" state on +the card. + +**Edit-before-accept** is one card with a `was_edited_before_accept` +flag (§5), not two. The card opens an in-place textarea pre-filled +with `proposed`; saving updates `proposed` to the contributor's +revision and flips the flag. The subsequent accept produces a single +commit; the commit body carries both the AI's original proposed text +under an `AI proposed:` section and the contributor's accepted +revision in the usual `proposed` body, so the timeline preserves what +was offered and what landed as distinct artifacts. The commit's +`On-behalf-of:` trailer per §6.5 names the contributor, not the AI; +the AI's authorship survives only in the `AI proposed:` body section +and in the `source_message_id` linkage. + +**Decline** is not a commit — no document state changed — but the +card persists. The card moves to its resolved stub form +(author · declined · the AI's short reason) and stays in the panel +and the `changes` table with `state='declined'`. There is no undo +affordance. The framework's claim depends on refusal being legible: +a contributor scrolling the resolved list should see what they +considered and rejected, not just what they accepted. To re-propose, +ask the AI again — the new proposal lands as a new card with the +declined predecessor still visible. + +### 8.10 Tracked-change markup and the review-mode toggle + +Two visual layers carry change information in the editor. The first +is a paragraph-margin marker — a thin gutter accent on any paragraph +that differs from the branch's open-session baseline, rendered by a +ProseMirror plugin against a baseline snapshot taken when the editor +opens. The second is inline `tracked-delete` / `tracked-insert` +markup at the exact range of an accepted change, with the deleted +text shown struck-through and the inserted text shown in an additive +style; both carry the change's ID via a data attribute, enabling the +click-to-card binding from §8.8. The margin marker is scannable +("did anything change in this region?"); the inline markup is +precise ("what changed here?"). The two answer different questions +and both are kept. + +The inline markup is session-local. Each accepted change is already +a clean commit on Gitea per §8.6, so the branch's canonical state at +any reload is the integrated text without markup. Regenerating +markup on load by diffing against earlier commits is technically +possible but adds a mechanism — a per-user seen-cursor for accepted +changes, an explicit dismiss UI — to solve a problem that DiffView +already solves durably and at higher fidelity. The editor is for +writing; layering permanent diff overlay on top of writable text +degrades the writing surface, so the markup clears on reload and +DiffView is the durable artifact for inspecting accepted changes in +context. + +DiffView is the read-only render surface invoked via a toolbar +toggle, carried over from the prototype per §8.15. It reads from the +`changes` table for the branch, reconstructs the markup for every +accepted change in branch history, and renders the result in-place +where the editor was. Hovering any marked span surfaces a tooltip +with the change's type badge (`ai` or `manual`), the model +identifier where applicable, the `was_edited_before_accept` flag +where set, the user prompt and selection-quote that drove the +change, and the AI's `reason`. The toggle is reversible; returning +to the editor restores the live writing surface and reattaches the +session-local baseline. + +### 8.11 Manual edits and collisions with AI proposals + +Manual edits accumulate in the editor and surface in the change-card +panel as a single pending card per flush window, growing one inline +word-diff per touched paragraph as the contributor types (debounced +on the order of a second — exact value is an implementation detail). +The card carries a quiet status line — `unsaved · auto-save in 4:32` +— that counts down toward the §8.6 idle threshold, plus an explicit +`Save now` button that flushes immediately. On flush — idle, branch +switch, or save — the card freezes into its resolved stub form with +the commit SHA from §8.6 attached, and a fresh pending manual card +opens on the next keystroke. The pending card's diffs and the +resolved card's diffs each match exactly one Git commit, preserving +the §8.6 evidence-unit framing. + +When an AI proposal's `original` text can no longer be located in +the current document because a manual edit has changed it since the +proposal was generated, the AI's card is marked **stale** — the +`stale_since` timestamp on the `changes` row is set, distinct from +`state`, which stays `pending` until the contributor acts. The card +surfaces a warning badge and a `Re-ask` button that re-prompts the +AI with the current text to regenerate the proposal anchored to the +new phrasing. Accept is gated behind a confirmation step on a stale +card — "text has changed since this was proposed; apply anyway?" — +for the case where the contributor judges the AI's proposal still +applicable despite the drift. Silent re-merging is refused: the +AI's argument was about a specific phrasing, and applying it to +different phrasing produces text neither party authored. + +### 8.12 Threads on a branch: anchors and the chat surface + +Every branch has a default `whole-doc` thread — the branch chat +referenced throughout §8 and §10. Beyond that default, sub-threads can +be anchored to a range or a paragraph (`anchor_kind` per §5) and +accumulate inside the same chat surface. The branch chat is the +unified chronological feed of every message across every thread on +the branch; threads are the structural grouping inside that feed, +not a separate surface. + +Range threads are created by the selection tooltip carried over per +§8.15. Submitting a tooltip prompt creates a new `thread_kind='chat'`, +`anchor_kind='range'` thread anchored to the selection, or continues +an existing open thread whose anchor overlaps the new selection +substantially — a threshold (default in the neighborhood of 50% of +characters) prevents one passage from spawning a forest of single-Q&A +orphans while still allowing a deliberately different framing to +start a new conversation. Paragraph threads are created via a +margin-icon affordance that appears on hover beside any paragraph; +clicking opens a thread-kind picker (chat or flag — flags per §8.13) +and a composer for the first message. + +In the feed, sub-threads render with a thin colored gutter and a +small anchor preview ("quoted: …"), grouping their messages visually +without breaking chronology. A top-of-chat disclosure shows aggregate +counts — `N open threads · M open flags` — and expands to a list of +open threads with anchor previews and per-thread filter affordances +that collapse the feed down to a single thread. Clicking the anchor +preview on any message scrolls the editor to that anchor and +highlights the range or paragraph. A `Reply` affordance on any +message posts back into its thread; the AI participant can be +invoked into any thread, not only the whole-doc default, and its +`` proposals carry the `thread_id` they were generated +within. + +A thread is resolved by its creator, by the branch creator, by any +of the RFC's owners or arbiters per §6.3, or by app-wide admins or +owners. Resolution writes `state='resolved'` with `resolved_by` and +`resolved_at`; in the chat feed, resolved threads collapse to a +one-line stub (expandable) and any editor anchor markers fade to +nearly invisible. Threads auto-transition to `state='stale'` when +their anchor no longer maps to the document — the paragraph deleted, +the range no longer locatable after edits — and the editor anchor +surface is replaced with an "(anchor lost)" affordance that disables +the scroll-to-editor binding. Resolved and stale threads stay in the +data; the chat feed's filter affordances surface or hide them on +demand. + +### 8.13 Flags + +A flag is the lightweight "I'm pointing at this, it's a problem" +gesture — a single declarative assertion that something is wrong, +incomplete, or unclear, anchored to a range, a paragraph, or the +document as a whole. The flag's entire content lives in +`threads.label` (§5); no back-and-forth is required to drop one. The +schema is shared with chat threads — `thread_kind='flag'` — so the +surfacing, resolution, and stale mechanics from §8.12 apply +unchanged. The distinction is gestural and visual, not structural. + +Creation requires contributor role but not contribute access to the +branch: any signed-in contributor who can read a passage can point at +it and say it's wrong. Flags are created via the same two affordances +as chat sub-threads — the margin-icon picker on a paragraph, or the +selection tooltip's `Flag` button alongside its prompt input — with a +short text input (capped on the order of 200 characters; exact value +is implementation detail) for the flag content. In the editor, flag +anchors render with a flag icon at the anchor in a color distinct +from the chat-thread margin icon. In the chat feed, flags appear +inline chronologically with a flag badge. In the top-of-chat +disclosure, flags are counted separately from chat threads. + +A flag can convert to a chat by anyone replying to it; the schema +accommodates this directly — `thread_messages` rows accumulate +against the flag's thread, and `thread_kind` stays `flag` as the +lineage marker. The AI participant can be invoked on a flag via an +`Ask Claude to propose a fix` button on the flag in the right pane, +which generates a `` proposal anchored to the flag's thread +and implicitly converts the flag to a chat, since the AI's response +is the first reply. Accepting a `` proposal generated from a +flag does *not* auto-resolve the flag — the flag-creator's claim may +have been broader than the specific change addressed, and the +explicit resolution gesture from §8.12 stays the only way to mark +the flag closed. + +Flags do not block PR merge per §10.5's principle — making them a +merge gate would re-create the "resolved to unblock" failure mode +the §10.5 rationale already refuses for review threads. But they are +prominent: the PR header from §10.3 surfaces the open-flag count +alongside the open-review-thread count, and the count is sized to +register on a reviewer's scan. + +### 8.14 Discuss-mode buffered proposals + +Per §8.3, discuss mode buffers AI-proposed changes rather than +applying them. The buffer is not a separate data store: every +`` block parsed from an assistant message becomes a `changes` +row with `state='pending'` immediately, regardless of mode. The mode +determines only what the UI renders. In contribute mode the panel +from §8.8 shows the pending rows as full cards; in discuss mode the +panel is hidden and a single contribution-CTA card surfaces in its +place, listing the count of pending proposals and offering a `Preview +proposed changes` disclosure that reveals the cards in read-only +form. The primary action on the CTA is `Start Contributing →`. + +What that action does depends on which branch the contributor is on. +On a non-main branch, `Start Contributing` is a pure mode flip — the +contributor is already on the branch, the pending rows are already +anchored to it, and surfacing the action affordances is the only +state change. On main, where contribute mode is unavailable per +§8.3, `Start Contributing` cuts a new branch from main's tip, +re-anchors the pending rows by mutating their `branch_name` from +`main` to the new branch's name — the changes haven't been acted on +yet, so there's no audit trail to corrupt — and navigates the +contributor to the new branch in contribute mode. The pending rows' +`source_message_id` continues to reference messages in main's chat; +the schema permits the cross-branch reference and the UI labels it +as `from a conversation on main`, while the `Forked from this +conversation →` link in the new branch's chat header from §8.4 +closes the loop in the opposite direction. + +Toggling contribute back to discuss on a non-main branch hides the +panel without touching data. Re-flipping to contribute resurfaces +the panel identically. The buffer-vs-applied distinction is purely +surface — under the data model, every AI proposal is a first-class +artifact from the moment it lands, on whatever branch the +conversation produced it on. + +### 8.15 Carryovers and implementation-deferred details + +These prototype affordances carry over with branch-scoped behavior: +the selection tooltip (elaborated in §8.12 as the range-thread +entry point); the review-mode toggle and `DiffView` for inspecting +accepted changes in context (§8.10); the discuss-mode banner +indicating read-only status; the `` / `` / +`` / `` AI protocol (§18). + +The following are implementation-level details that the build session +will decide: + +- New-branch naming format on the "Start Contributing from main" + flow (§8.14). +- Exact idle-window length for the manual-edit commit flush (§8.11). +- The compressed-conversation rendering in PR review (§8.5). +- The exact overlap threshold for continue-vs-create-new on + range-anchored threads (§8.12). +- Stable paragraph-ID machinery for paragraph-anchored thread + payloads (§5's `anchor_payload`). +- The 200-character cap on flag content (§8.13). + +Super-draft document-pane behavior, the propose flow, and editing a +super-draft body are covered in §9 — a sibling section that maps the +same machinery onto the super-draft surface, with meta-repo edit +branches as the unit of work in place of per-RFC branches. + +--- + +## 9. The super-draft view and lifecycle + +The catalog (§7) settles the left-pane treatment of super-drafts; the +active-RFC view (§8) settles the surface a graduated RFC is read and +edited on; the graduation flow (§13) settles the bridge between +super-draft and active. This section settles the middle of that arc: +how an idea enters the catalog at all, what a super-draft looks like +once it does, and how its body and conversation evolve up to the +graduation moment. + +The framing claim from PHILOSOPHY.md does most of the structural work +here. Super-drafts are the moment a word enters the conversation; +most proposals will not survive the argument, and that is fine. The +super-draft view is where survival or non-survival gets argued out — +which means the argument tooling has to be the same tooling §8 spent +its length specifying. Stripping the revision flow on super-drafts +would strip it from the phase where definitions are most generative. + +### 9.1 Proposing a new RFC + +The "+ Propose new RFC" affordance in the left pane (§7.3) opens the +**propose modal**. Available to any contributor; anonymous viewers +see the affordance replaced with a sign-in CTA per §8.7's pattern. + +The modal collects four fields: + +- **Title** — required short text input. The word or topic this RFC + would define. Contributor-typed; title-first matches the natural + cognitive flow, since a proposer arrives with a word in mind, not + with a pitch in search of a word. +- **Slug** — deterministically kebab-cased from the title as the + contributor types, inline-editable. Validated for uniqueness + against `rfcs/` on the meta-repo main *and* against the slugs of + any open idea PRs, so concurrent proposers cannot collide. A + collision surfaces inline ("`open-human-model` is taken — try + `open-human-model-2`?"). The API re-checks atomically at submit. +- **Pitch** — required textarea. One or two paragraphs answering + "why this RFC is needed," contributor-typed. Becomes the entry + file's body per §2.1. +- **Tags** — optional chip input with AI-suggested chips populating + after a debounce on the pitch. Contributor can accept, dismiss, or + type their own. + +No proposer name or email — the logged-in identity is canonical and +need not be retyped. No proposed-owner or working-group fields — +ownership flows through the post-merge claim flow (§13.1), and +arbiters are admin work, not the proposer's call. AI's drafting role +is intentionally narrow: tag suggestions only. The proposer is +making a specific claim about a specific word, and having AI propose +the claim for them would undercut the gesture. + +Primary action: **"Open proposal PR"** — naming the actual Git +artifact produced, consistent with §10.1's *Open PR* and §13's +*Graduate*. + +### 9.2 The proposal PR + +Submitting the modal opens a PR against the meta repo per §2.2, +adding exactly one file at `rfcs/.md`. The file's frontmatter +is populated from the modal and session: + +- `slug`, `title`, `tags` — from the modal. +- `state: super-draft`, `id: null`, `repo: null`, + `graduated_at: null`, `graduated_by: null` — fixed at creation. +- `proposed_by: `, `proposed_at: ` — auto. +- `owners: []` — empty; the claim flow (§13.1) fills this. +- `arbiters: []` — empty; arbiters are admin work, not the + proposer's call. + +The file's body is the pitch as typed. The bot is the author per §1; +the standard `On-behalf-of:` trailer per §6.5 names the proposer. + +The PR title and the file-add commit subject share a fixed pattern: +**`Propose: `**. Mechanical, scannable — an owner viewing the +meta-repo's PR list sees "Propose: Open Human Model," "Propose: +Trait," "Propose: Consent" and triages at a glance. + +The PR description is AI-drafted from the pitch — two or three +sentences in spec voice making the *case for catalog admission*, +distinct from the pitch body itself. The admission case answers: is +this in scope, does it overlap an existing entry, is it the right +shape for a single RFC. Audience is an owner or admin, per §13.1's +framing for the merge actor. Editable inline in the modal before +submit, and editable post-open on the PR by the proposer or any +owner/admin. This is the propose-flow analogue of §10.2's +AI-drafted-from-chat description — different source material, same +role. + +No reviewer picker. Meta-repo owners and admins are the implicit +reviewer set, same logic as §10.2. + +### 9.3 The pending-idea view + +Submitting the modal closes it and navigates the proposer to the +**pending-idea view** — the in-app read surface for any open idea +PR, available to anyone per §7.3's "Reading is open to all." The +left pane's pending-ideas disclosure expands and highlights the new +entry. There is no success interstitial; the navigation *is* the +success state, consistent with §1's claim that the app is the +contribution surface. + +The pending-idea view renders the proposed entry's body and +frontmatter (read-only) using the same renderer §9.4 specifies for +super-drafts proper. A status banner reads "Pending idea — awaiting +review." The header strip carries: + +- **Withdraw proposal** — proposer-only. Closes the meta-repo PR. + The view enters a "Withdrawn" terminal read-only state, mirroring + §10.8's PR treatment. +- **View on Gitea** — unobtrusive footer link. The power-user + escape hatch. +- **Merge proposal** — visible to owners and admins per §6.1. + Merging is what creates the super-draft entry on meta-repo main. +- **Decline** — visible to owners and admins. Opens a two-step + dialog: first a comment composer (required textarea), then a + preview-and-confirm step rendering exactly what the proposer will + read — decliner display name, decline date, the comment verbatim. + The admin can edit the comment from the preview or confirm to + send. Confirming closes the meta-repo PR with the comment recorded + both as a meta-repo PR comment (the durable Git artifact) and as a + system-author message in the pending-idea chat thread ("Declined + by @admin: <comment>"), so the chat record per the rules in this + section carries the act inline. The preview step is the + ceremony — decline is the sister gesture to graduation, and seeing + what the proposer will read before sending makes the cost of the + act concrete. + +A chat thread accumulates on the pending-idea view pre-merge — +contributors can argue about whether the entry belongs in the +catalog before it is admitted. On merge, that chat migrates to the +super-draft's main chat per the same principle §13.4 commits for +graduation: chat follows the work. The data path is straightforward. +The threads carry `rfc_slug = <proposed slug>` pre-merge (slugs are +reserved during the idea PR per §9.1's uniqueness check), and on +merge they simply surface under the super-draft's main view with no +data movement. On decline, the threads stay attached to the closed +PR as historical record and do not surface in any default view, the +same treatment a withdrawn entry receives. + +Outcome signals to the proposer are narrow. On next visit after +merge or decline, the pending-idea view is either the super-draft +view itself (merged) or a read-only "Declined" banner (declined), +and a one-time in-app toast surfaces — "Your proposal *Open Human +Model* was merged. It's now a super-draft." or "Your proposal *Open +Human Model* was declined." The "Declined" banner carries the +decliner's display name, the decline date, and the comment from the +two-step dialog verbatim, rendered above the proposal body and +frontmatter (which remain readable below for context). Beneath the +banner, a small "Propose a revised entry" affordance opens the +propose modal pre-filled from the prior proposal's title, slug, and +pitch — the philosophy frames non-survival of a proposal as +expected, and a one-step path back to the modal honors a proposer +who has absorbed the decline reason. Beyond the toast and the +banner, the proposal-merged and proposal-declined events also fire +into the proposer's inbox as personal-direct notifications per §15.4 +(default-on email category), giving out-of-session reach for an +event whose timing the proposer cannot anticipate. + +### 9.4 The super-draft view + +Once the idea PR merges, the entry exists as a super-draft per §3, +and the catalog renders it as a `super-draft` row per §7.2. +Selecting it opens the **super-draft view** — a sibling surface to +§8's active-RFC view, sharing the three-column layout, the +breadcrumb, and all of §8's center-pane mechanics. + +The differences are scoped. There is no integer ID in the breadcrumb +per §2.3. The breadcrumb dropdown lists open meta-repo edit branches +(per §9.5) in the place an active RFC lists its branches, and the +dropdown's first position is **"canonical body"** — the entry as it +appears on meta-repo main — in the place an active RFC's main sits. +There is no PR flow scoped to *this* RFC's own repo (it has none +yet); there can be open body-edit PRs against the meta repo, surfaced +inline with the edit branches in the dropdown. + +The document pane uses the same Tiptap editor as active RFCs, in +read-only mode by default, identical to §8.2. The toolbar shape, the +selection-tooltip from §8.12, the §8.13 flag affordances, and the +review-mode/DiffView toggle from §8.10 all map across. The discuss +vs. contribute mode toggle from §8.3 exists; the canonical body is +in discuss mode like main on an active RFC, and the "Start +Contributing" affordance cuts an edit branch per §9.5 rather than +flipping mode inline. + +§8.7's read-only fallbacks apply identically. Anonymous viewers see +the full body, the selection tooltip works but its submit is +disabled, the chat input renders disabled with a "Sign in to chat" +hint, and the "Start Contributing" affordance is replaced with +"Sign in." Muted contributors see the same surface with a muting +banner per §6.2. + +### 9.5 Editing a super-draft body + +Super-drafts have no per-RFC repo, so they have no branches in the +§8 sense. Edits to the body propagate through PRs against the meta +repo per §2.2's framing. To carry the §8 revision flow onto this +target without ceremony, the unit of work is a **branch on the meta +repo, scoped to the super-draft's edit**. + +The super-draft view's center column behaves like an active RFC's +`main` view per §8.3: read-only by default, with a "Start +Contributing" CTA. Activating it cuts a fresh branch off the meta +repo's `main`, naming it `edit/<slug>/<auto-name>` (exact format an +implementation detail), and lands the contributor on that branch in +contribute mode. The branch's only file under edit is +`rfcs/<slug>.md`; the editor never exposes the rest of the meta +repo's contents, and attempts to navigate elsewhere on the branch +fall back to the super-draft view. + +On the edit branch, everything from §8.4 through §8.14 applies +unchanged. The per-branch chat (§8.4), AI proposals materializing +as `<change>` rows, accept/decline/edit-before-accept (§8.9), +manual-edit flushes (§8.6), the change-card panel (§8.8), range and +paragraph sub-threads (§8.12), flags (§8.13), DiffView (§8.10), +stale-change handling (§8.11). The "branch" abstraction §8 was +written against is the meta-repo edit branch; the machinery does +not care whether the underlying repo is per-RFC or meta. + +Opening a PR is §10.1's gesture, with the meta-repo branch as +source and the meta repo as target. The PR creation modal (§10.2), +the review page (§10.3), the review-comment surface (§10.4), and +the seen-cursor mechanism from §10.3 all apply unchanged. The merge +actor set is the **super-draft's owners and arbiters per §6.3, +plus app-wide admins/owners per §6.1**. An unclaimed super-draft has +no owners, so until the claim flow (§13.1) runs, only app-wide +admins/owners can merge body-edit PRs — sensible because an +unclaimed super-draft is by definition awaiting an owner, and +admin oversight is the only path to canonicalizing edits in the +interim. + +Multiple body-edit branches can coexist on the same super-draft. The +super-draft view's breadcrumb dropdown lists them exactly the way +an active RFC's lists its branches. Merge conflicts invoke §10.9's +resolution-branch replay path against the meta repo. + +Frontmatter edits — title and tags — are out of scope for the +editor's in-line edit surface in v1. A small **metadata pane** on +the super-draft view permits title and tag edits; each edit produces +a tiny meta-repo PR via the bot, distinct from a body-edit branch. +Slug renames are not supported in v1 — a slug rename is a file +rename in Git plus a cache-key rewrite, rare enough to defer to a +future topic (see §19.2). The proposer's slug choice is canonical +until graduation, at which point the integer ID becomes the stable +handle per §13.2. + +### 9.6 Chat and threads on a super-draft + +The chat and thread model from §8.4, §8.12, and §8.13 extends to +super-drafts in the natural way: + +- The super-draft has a **main chat** — the whole-doc default + thread, stored as a `threads` row with the super-draft's slug as + `rfc_slug` and `branch_name = null`, identical in shape to an + active RFC's main thread per §8.4. This is the durable + conversation surface about the canonical body as it appears on + meta-repo main. +- **Each meta-repo edit branch has its own chat** per §8.4, with + `branch_name` set to the edit branch's name. On merge or close, + the chat persists attached to the (now-closed) branch as + historical record, exactly per §8.4's rule. After §12's 90-day + deletion timer fires, the metadata row remains in the cache for + historical reference and the chat data remains in the app + database. +- **Range and paragraph sub-threads (§8.12) and flags (§8.13)** + apply on both the super-draft's main view and on edit branches. + The AI participant is invocable on any thread. The + selection-tooltip and margin-icon affordances from §8.12 work + identically. +- **Anchor stability.** §8.12's stale mechanic is general enough to + cover both cases. On an edit branch, anchors churn + commit-to-commit, same as on an active-RFC branch. On the + super-draft's main view, anchors churn only at body-edit-PR merge + boundaries — same volatility as on an active RFC's main. Anchors + that fail to relocate get the "(anchor lost)" affordance from + §8.12. + +§5's existing `threads` schema accommodates all of this without +column changes. The interpretive rule recorded in §5 is: when the +entry referenced by `rfc_slug` is in state `super-draft`, +`branch_name` names a branch on the meta repo rather than on a +per-RFC repo. The `changes` table inherits the same rule. + +### 9.7 Visibility and contribute on a super-draft + +§11 specifies visibility (read) and contribute (push) for branches +on active-RFC repos. Because §9.5 maps super-draft body edits onto +meta-repo edit branches, §11 transfers in place with two +super-draft-specific clarifications: + +- **The super-draft's canonical body is always publicly readable.** + It lives on the meta repo's main branch, which is public by + definition (§2, §14.2). There is no `read_public` toggle at the + super-draft level — nothing to hide, nothing to flip. The + pending-idea view (§9.3) is publicly readable for the same reason: + meta-repo PRs are public. +- **Edit branches inherit §11 in full.** Default + `read_public = true`, default `contribute_mode = just-me`, + per-branch contribute grants per §6.4, §11.3's PR-becomes-public + rule, §11.4's chat-inherits-from-branch rule, §12's 30/90 hygiene + timers. The set of people who can flip an edit branch's settings + is the branch creator, the super-draft's owners/arbiters (where + any have been claimed), and app-wide admins/owners. Until the + claim flow (§13.1) runs, that set is the branch creator and + app-wide admins/owners only — falling out of §6.3's strict + super-set framing without amendment. + +"Contribute" as a super-draft-scope verb resolves to the concrete +set of gestures: chatting on the super-draft's main thread, opening +an edit branch via "Start Contributing," posting in an edit branch's +chat (subject to per-branch grants), claiming ownership per §13.1, +dropping a flag per §8.13, and posting on an open idea PR's +discussion pre-merge. All require contributor role per §6.1; none +requires more than that on the super-draft itself, except per-branch +grants on a `just-me` or `specific` edit branch. + +The super-draft's main chat is publicly readable; posting requires +contributor — identical to an active RFC's main chat per §11.4 plus +§6.1. Anonymous viewers see the same read-only fallback per §8.7. + +### 9.8 Graduation handoff additions + +§13's graduation sequence was written before this section's +machinery existed. The mechanics §13 needs to absorb fold inline +into §13.2 and §13.4 in their respective sections; the substantive +additions are captured here for cross-reference: + +- **Open body-edit PRs block graduation.** §13.3's step 3 removes + the meta-repo entry's body field, and an open body-edit PR + post-graduation would attempt to re-introduce a body to a + frontmatter-only entry. The Graduate dialog disables the confirm + button if any meta-repo PR is open against `rfcs/<slug>.md`. The + precondition is enforced before the bot starts §13.3's sequence, + so §13.3's rollback complexity does not grow. +- **Bare edit branches survive graduation.** Edit branches without + an open PR are not blocked. They remain on the meta repo subject + to §12's hygiene timers. The contributor can re-cut against the + new RFC repo's main if they still want the work. The branch chat + persists per §8.4 as historical record even after auto-close, so + the argument that produced the work is preserved regardless of + whether the work itself merges. +- **Chat migration includes range and paragraph sub-threads.** + §13.4's chat-follows-the-work rule covers the whole-doc main + thread; it extends to range and paragraph sub-threads on the + super-draft's main view, which migrate as part of the same + movement. Anchors re-resolve against `RFC.md` on the new repo; + since §13.3's step 2 seeds `RFC.md` from the super-draft body + verbatim, anchors typically locate the same content. Where they + do not, §8.12's stale mechanic engages. +- **Pre-graduation history surfaces from the new RFC view.** + Meta-repo edit-branch chats, flag threads, and `changes` rows + stay attached to their original `branch_name` on the meta repo; + they do not migrate. A **"Pre-graduation history"** affordance on + the new RFC view surfaces these — the slug remains the canonical + key per §2.3, so the query is a straightforward lookup of + `threads` and `changes` rows where `rfc_slug = <slug>` and + `branch_name` begins with `edit/<slug>/`. UI affordance; no data + movement, no rollback cost. + +--- + +## 10. The PR flow + +PR opening is structurally separable from doing the work. Branches +accumulate commits independently per §8.6 — one per accepted AI change, +debounced flushes for manual edits — so opening a PR is not a side-effect +of editing. It is a deliberate "ready for review" gesture against an +existing branch, and it is the surface on which the RFC's arbiters consume +the branch's evidence and decide whether to fold it into main. + +### 10.1 Opening a PR + +PRs open from an explicit **Open PR** affordance on the branch view. The +only hard precondition is that the branch has at least one commit ahead +of main. Any pending manual edits buffered in the editor flush +immediately at submit time — the same flush §8.6 specifies for branch +switch and explicit save. Pending AI change-cards in the branch chat — +proposed but neither accepted nor declined — remain pending. They are +chat artifacts, not document state, and they travel into the PR view as +part of the conversation surface; making them blocking would force +contributors to perform decline-busywork on every speculative suggestion +just to file for review. + +If the branch is currently private (per §11.1), the submit affordance +opens a confirmation modal first — "Opening this PR will make the branch +and its history publicly readable. Continue?" — per §11.3's universal- +public rule. + +### 10.2 The PR creation modal + +The modal collects two fields, both AI-drafted from the diff plus the +branch chat, both editable inline before submit: + +- **Title** — a one-line structural description of the change, in spec + voice. What was edited, in what way. +- **Description** — two to four sentences pulling from the chat: what + was argued, what shifted, what the contributor is asking the arbiters + to consider. + +The model is told the audience is *an arbiter*, not Ben specifically — +the framework has to scale past one person. Title and description remain +editable post-open by the contributor or any of the RFC's arbiters. + +There is no reviewer picker. The RFC's arbiters (§6.3) are the implicit +reviewer set; surfacing a per-PR picker would either duplicate that or +imply a notion of non-arbiter reviewers with weight that the spec +deliberately does not introduce. + +### 10.3 The PR review page + +The PR page inherits the three-column shape from the RFC view (§8.1). +The catalog on the left is unchanged. The center column carries the diff +in place of the editor — toggleable between unified and split views. The +right column carries the compressed conversation per §8.5, with the +review-comment surface inline below it. A header strip above the diff +carries title, editable description, status, the merge button (§10.5) +when the viewer is an arbiter or app-wide admin/owner, and aggregate +counts surfaced for reviewer scanning: open review threads (§10.4), +open chat threads on the branch (§8.12), and open flags (§8.13). The +flag count in particular is sized to register at a glance. + +The conversation is rendered per §8.5's compressed default: messages +that produced accepted changes (and immediate context) expanded, the +rest behind a "Show full conversation" toggle. + +**What changed since my last visit.** Each PR records a per-user +seen-cursor — the most recent commit and the most recent thread message +the viewer has seen on this PR. New diff hunks and new conversation +messages since that cursor render with a subtle accent on the next visit. +The cursor advances on view; reviewers do not have to mark anything as +read. This is what makes incremental review tractable on a PR that is +still receiving commits (§10.6). + +### 10.4 Review comments + +Review comments are messages in the branch chat, not a separate surface. +A reviewer selecting a range in the diff and posting a comment creates a +thread with `anchor_kind = range` — where the range refers to the post-PR +document state — and `thread_kind = review`, a new value alongside `chat` +and `flag` in §5's threads table. Review threads surface in the PR view's +right column inline with the AI conversation, visually distinguished. +Both arbiters and the original contributor (and the AI participant, on +invocation) post into review threads on equal footing; they are +resolvable per §5's existing `state` field on threads. + +This is a deliberate refusal of the partition between "conversation" and +"review" that other PR surfaces draw. The framework's claim, in +`PHILOSOPHY.md`, is that the transcript of the argument is the evidence +the definition was earned. Separating review onto its own surface would +say the opposite — that review is a different kind of thing from +conversation. It isn't. The disagreement an arbiter raises about a +proposed tightening is the same kind of disagreement that produced the +proposed tightening in the first place; the only difference is its +position in the timeline. + +Because review threads live in the branch chat, §8.4's persistence rule +applies to them unchanged. On merge they remain attached to the +(now-closed) branch as part of the historical record. + +### 10.5 Merge + +Per §6.3, the RFC's owners and arbiters can merge; per §6.1, app-wide +admins and owners retain this capability too. The PR header surfaces a +single **Merge** button to those viewers. Merging produces a +no-fast-forward merge commit on main carrying the standard +`On-behalf-of:` trailer per §6.5 and preserving the per-acceptance +commit nodes from §8.6 as individual reachable commits in main's +history. The framework's evidence claim depends on those commits +remaining inspectable — squash-merging would collapse the argument into +a single anonymous artifact and is not supported. + +Merge is hard-blocked only by Git-level conflicts with main. Open +review threads, pending AI change-cards, unresolved chat threads, and +open flags (§8.13) do not block merge. Making any of those a merge +gate would re-create the GitHub failure mode where threads get hastily +"resolved" to unblock a button — which corrupts the evidence record +the framework exists to preserve. + +### 10.6 Updates after open + +New commits — whether from accepted AI changes on the open PR's chat +or from manual-edit flushes per §8.6 — push to the branch and +immediately surface on the open PR. The diff re-renders; new hunks and +new conversation messages are accented via the seen-cursor mechanism +from §10.3. There is no notion of review invalidation; reviews are not +discrete approval gestures (§10.5), so there is nothing to invalidate. + +Manual-edit flushes additionally drop a system-author message into the +branch chat noting the flush — e.g. "manual edit: 12 lines changed in +§3.2." The conversation surface is the framework's canonical +evidence timeline, and a silent diff shift would corrupt it. +AI-accepted-change commits need no separate marker: the assistant +message whose `<change>` block produced the commit is already in the +chat, and §5's `changes.commit_sha` binds the two. + +### 10.7 Post-merge + +After merge, the PR page renders identically with a "Merged" banner in +the header strip. The diff, the compressed conversation, and all review +threads become read-only. The branch enters the closed state per +§12's hygiene table (the 90-day deletion timer starts; owners and +arbiters can still pin to disable it). The branch chat persists per +§8.4 as historical record, with new posts disabled. The PR page remains +at its stable URL indefinitely — it is the canonical surface for "show +me how this definition came to be." + +Post-merge questions about the merged work belong on main's chat (§8.4), +not on the merged branch's. Main is the surface for ongoing argument +about the canonical document; a new branch cut from main is the path to +propose a revision. + +### 10.8 Withdrawing a PR + +Either the original contributor or any of the RFC's arbiters can +withdraw an open PR. Withdrawal renders the PR page read-only with a +"Withdrawn" banner — identical archive treatment to a merged PR, with a +different label. The branch is *not* deleted; it remains in its current +state, subject to §12's hygiene timers. Withdrawing a PR is not the +same gesture as withdrawing the work — the contributor may want to +return to it, or another contributor may pick it up. + +### 10.9 One PR per branch; conflict resolution by replay + +A branch may have at most one open PR at any time. Attempting to open a +second surfaces "This branch already has an open PR." + +Merge conflicts with main are surfaced on the PR page as a read-only +banner identifying the conflicting regions and offering a **Start +resolution branch** affordance. Activating it cuts a fresh branch off +the current tip of main, replays the source branch's diff into it — +running the AI participant against unambiguous conflicts and surfacing +the rest for the contributor to resolve manually — and opens a new PR. +The resolution branch's chat is seeded with a "Forked from this +conversation →" link to the original branch's chat per §8.4, so the +argument chain remains traceable across the hop. The original PR +auto-closes when the resolution PR merges. + +Conflict resolution by fixup commit on the existing branch is not +supported. Per-accepted-change commit granularity (§8.6) is the +framework's evidence unit; admitting plumbing commits — "fix merge +conflict with main" — into that timeline would dilute the signal each +commit is meant to carry. + +--- + +## 11. Branches and PRs: visibility, contribute, lifecycle + +### 11.1 Visibility (read) + +A branch's `read_public` defaults to true. Anyone, including +anonymous viewers, can see the branch exists, view its diffs, and +view its associated chat. The branch creator can flip a branch to +private, which restricts read to: the creator, any explicit +grantees, and the RFC's owners and arbiters. Owners and arbiters +can flip it back. + +### 11.2 Contribute (push) + +Default `contribute_mode` is `just-me` (see §6.4 for the three +modes). The creator and the RFC's owners/arbiters can change this +setting at any time. + +### 11.3 PRs are always fully public + +Opening a PR makes the branch and its history publicly readable +regardless of prior visibility. If the branch was private, the PR +modal warns the user: "Opening this PR will make the branch and +its history publicly readable. Continue?" There is no concept of +a private PR. + +### 11.4 Chat visibility inherits from the branch + +The chat thread attached to a branch is readable by exactly the +people who can read the branch. Anonymous users can read branch +chat on a public branch but cannot post. Posting requires +contributor or higher. + +### 11.5 Branch lifecycle hygiene + +A branch with no associated PR auto-closes at 30 days from last +commit. A closed branch is deleted at 90 days. Closed branches +remain publicly readable (if they were public) through a "show +closed" filter in the RFC's tree view — closing is a state, not +a censorship event. Deleted branches are gone from Gitea but a +metadata row remains in the cache for historical reference. + +The 30/90 timers reset on any push to the branch. Owners and +arbiters can pin a branch to disable the auto-close timer if the +work is paused but legitimately ongoing. + +--- + +## 12. Branch hygiene policy (formalized) + +| State | Trigger | Effect | +| ----------------------- | ----------------------------- | -------------------------------------------- | +| Open | branch created | normal contribution | +| Open, idle | 30 days no commit, no PR | auto-close | +| Closed | auto or manual close | hidden from default tree, surfaceable | +| Deleted | 60 days after close (90 from last activity) | branch removed from Gitea, row remains | +| Pinned | owner/arbiter pins | auto-close disabled | + +Future story (not v1): out-of-band reopening of a deleted branch by +email request to an owner. + +--- + +## 13. The graduation flow (super-draft → active RFC repo) + +Graduation is initiated by an owner or admin clicking "Graduate to RFC +repo" on a super-draft's page. The button is disabled with a tooltip +when the super-draft has no owners (see §13.1) or when any meta-repo +body-edit PR is open against `rfcs/<slug>.md` (see §9.8 — open +body-edit PRs would attempt to re-introduce a body to a frontmatter- +only entry after step 3 of §13.3). Bare edit branches without an open +PR do not block graduation; they remain on the meta repo subject to +§12's hygiene timers. + +### 13.1 Claim ownership (prerequisite) + +If a super-draft has no owner, any signed-in contributor can click +"Claim ownership," which opens a PR against the meta repo adding their +username to the `owners:` field of the entry. Owners and admins can +merge. (A self-merge window for un-acted claims is not enabled in v1; +configurable later if needed.) Multiple claims simply append. + +### 13.2 The Graduate dialog + +Clicking "Graduate to RFC repo" opens a small dialog with three +editable fields: + +- **Integer ID** — pre-filled as `max(existing integer IDs) + 1`, + formatted as `RFC-NNNN`. Editable to allow gap reservations but the + default is just the next number. +- **Repo name** — pre-filled as `rfc-NNNN-<slug>`, editable but + constrained to valid Gitea repo names. +- **Initial owners** — pre-filled from the entry's `owners:`, with an + "add owner" picker. Must have at least one. + +Each field validates inline as the admin types, with a short +debounce, against the catalog cache and a regex — integer-ID +collision against existing IDs, repo-name pattern against valid +Gitea name rules, the at-least-one-owner constraint on the picker. +Errors render as a short line of text beneath the offending field. +The repo-name collision check is re-issued atomically server-side +on confirm, since a concurrent graduation could land between +dialog-open and submit. While any field is invalid, the confirm +button is disabled and its tooltip names the first blocker +specifically — "Integer ID 42 is already taken," "Repo name must be +lowercase letters, digits, and dashes," "Add at least one initial +owner" — the same grammar the precondition popover below uses, so +the dialog and the gate read as one surface rather than two +competing styles. + +The dialog's confirm button is also disabled when the preconditions +from §13's opening paragraph fail — no owners on the entry, or any +open meta-repo PR against `rfcs/<slug>.md`. The disabled button +opens a small popover on hover or click that lists each failing +precondition as its own line item with an inline remediation +affordance per item. "No owners claimed yet" surfaces a "Copy share +link" affordance for surfacing the super-draft to a would-be +claimer, plus a secondary "Claim ownership yourself" — admins are +contributors per §6.1, so they can claim if they intend to graduate +solo. "N open body-edit PRs" expands inline within the popover to a +list of the offending PRs, one per row, carrying each PR's title, +author, and last-activity timestamp plus inline merge, withdraw, +and open-in-new-tab affordances; admins hold §6.3 authority on +those PRs and can resolve the precondition from the popover without +leaving the Graduate context. + +The preconditions are enforced before the bot starts §13.3's +sequence, so §13.3's rollback complexity is unchanged. + +### 13.3 The transactional sequence + +Confirming the dialog runs this sequence as the bot: + +1. Create the new Gitea repo. +2. Seed it with an initial commit on `main` containing: + - `README.md` (header pointing at the meta-repo entry, plus the + super-draft's pitch body migrated over). + - `RFC.md` (the actual document, starting from the super-draft body + or a template if the body is empty). + - `.rfc/metadata.yaml` — mirror of the meta-repo frontmatter for + future tooling. +3. Open a PR against the meta repo updating the entry: `state: active`, + `id: RFC-NNNN`, `repo: <new repo URL>`, `graduated_at: <timestamp>`, + `graduated_by: <admin username>`. The meta-repo entry's body field + is removed (frontmatter only, plus a generated "see the full RFC at + <repo>" link). +4. Auto-merge the PR (the same admin who clicked the button is the + merge actor). +5. Webhook flow updates the SQLite cache; left pane reflects the new + state immediately. + +The dialog renders the sequence in flight as a stack of the five +named steps with per-step states — `pending`, `running`, `done`, +`failed`, `not reached` — and a one-line caption beneath the current +step naming the concrete operation ("Creating repository +wiggleverse/rfc-0042-open-human-model…"). The stack streams from +the server via the SSE surface in §17, one event per step +transition. On success, a brief "Graduation complete" frame holds +for a moment before the dialog closes and the catalog row +transitions per §7.2. + +If any step fails partway, the app rolls back: deletes the +half-created repo, abandons the unmerged PR, surfaces a clear error +to the admin. The rollback is itself a visible step appended to the +stack on failure — the admin sees that cleanup ran, not just that +the act failed. The failed step turns red, later original-sequence +steps mark "not reached," and a "What happened" panel renders below +the stack explaining what was rolled back, what wasn't (if anything +is unrecoverable), and what to do next. The panel persists until the +admin dismisses it — a failure surface is not auto-dismissed. +Graduation is rare enough to afford this level of care. + +### 13.4 Chat history follows the work + +The chat thread attached to the super-draft moves to the new repo's +main-branch chat at graduation. This covers both the whole-doc main +thread per §8.4 and any range or paragraph sub-threads per §8.12 +anchored to the super-draft's main view; anchors re-resolve against +`RFC.md` on the new repo and, where they fail, §8.12's stale +mechanic engages. The meta-repo entry retains a generated link +"Conversation continues at <repo URL>." The chat is about the RFC, +not the meta-repo entry, and it should travel with the work. + +Meta-repo edit-branch chats, flag threads, and `changes` rows from +the super-draft phase **do not migrate**. They stay attached to +their original `branch_name` on the meta repo and surface from the +new RFC view via a **"Pre-graduation history"** affordance — a +straightforward lookup of `threads` and `changes` rows where +`rfc_slug = <slug>` and `branch_name` begins with `edit/<slug>/` +(the slug remains the canonical key per §2.3, before and after +graduation). UI affordance; no data movement, no rollback cost. + +The affordance renders as a section in the §8.1 breadcrumb dropdown +on the new RFC view, alongside `main`, open branches, and open PRs, +headed "Pre-graduation history (N)" with each pre-graduation edit +branch listed as its own row. Selecting a row swaps the center +column to a read-only render of that branch's body at its last +commit and the right column to that branch's chat, with associated +change-cards and flags inline — the same machinery a closed branch +on an active RFC uses per §10.7 and §11.5. Anchors on pre-graduation +threads resolve against the pre-graduation body, not against +`RFC.md` on the new repo. The pre-graduation set is kept distinct +from the post-graduation "Show closed branches" filter in the same +dropdown — "branches that closed normally on this repo" and +"branches that lived on the meta repo before this repo existed" are +semantically different sets, and conflating them would obscure the +graduation hop. + +### 13.5 Graduation is not reversible + +Once an entry is graduated to `active`, the path forward is +`withdrawn`, not back to `super-draft`. Reversing graduation cleanly +is operationally messy (existing commits in the new repo, etc.) and +the cost of not having it is low — withdraw and re-graduate as a +fresh idea if needed. + +--- + +## 14. Outside the RFC view: landing and the philosophy surface + +The bulk of this spec describes what is inside an RFC and what is in +the left pane around it. This short section captures the structural +decisions about everything *outside* the RFC view — the app's chrome +and its public face. + +### 14.1 Pre-login landing + +The app's root URL, accessed by an unauthenticated visitor, renders a +landing page consisting of the title, the subtitle, and the short-form +deck from the top of `PHILOSOPHY.md` (see §2). Beneath the deck, a +single primary action: "Sign in with Gitea." Beneath that, a secondary +link: "Read the full philosophy" → `/philosophy`. + +This is the front door. It sets expectation before the user encounters +the mechanics, so the mechanics (super-drafts, graduation, public +arguments, AI participation in chat) read as load-bearing rather than +novel. + +### 14.2 The `/philosophy` route + +Authenticated and anonymous visitors alike can reach `/philosophy`, +which renders the full body of `PHILOSOPHY.md` from the meta repo. The +content is sourced from the meta repo's main branch, cached and +refreshed on the same cadence as RFC bodies (§4). The page is plain +markdown rendering with no editing affordance. + +### 14.3 Persistent "About" link + +The app's header carries an unobtrusive link — "About" or "Why this +exists" — to `/philosophy`, available from every authenticated screen. +The philosophy is a reference document for the framework's mechanics, +not just marketing; a contributor mid-PR who wonders why a conversation +is public should be able to find the answer in two clicks. + +### 14.4 What is not done + +The philosophy is not pushed at returning users via banners or modals, +is not gated in front of the working surfaces, and is not embedded +inside the document pane. It earns its place by being available, not +by being insisted on. + +The visual design of the landing page and the `/philosophy` route — +typography, layout, illustrations if any — is deferred. The structural +decisions above are the binding part. + +--- + +## 15. Notifications + +The framework's public-async-work model has accumulated signals across +every flow — proposal-merged, proposal-declined, contribute-granted, +PR-opened-on-watched-RFC, commit-added, review-thread-new, change- +proposed-on-edited-passage, graduation-ready, graduation-complete, +withdrawal — without committing a surface to receive them. This +section is that surface. + +The framing constraint is from `PHILOSOPHY.md`: the framework is a +tool for thought, not a feed. Notifications exist because the public- +async work model breaks without them — a contributor whose thread of +work moves on Tuesday cannot first find out the following Saturday +when they happen to log in — and they must read as honest signal +rather than engagement bait. Defaults are conservative; the +contributor raises volume deliberately rather than dialing it down +under pressure. The system never invents attribution where none +exists (§15.9), never aggregates the user's own gestures into other +people's inboxes, and never optimizes for return visits as an end +in itself. + +The signals themselves are produced by gestures specified elsewhere +in the spec. This section commits the **surface** that receives them, +the **subscription model** that decides whether a given user receives +a given signal, the **storage shape** that makes triage tractable, and +the **out-of-session channels** (email, digest) that let asynchrony +actually work. + +### 15.1 The signal-surface stack + +Five surfaces, each with one narrow job: + +- **In-app inbox** — the durable, global triage surface. One mental + space across all RFCs the contributor has any relationship to, with + per-RFC and per-category filtering. The substrate for §15.2. +- **Badges** — ambient pull-ins. A single integer beside the inbox + icon in the app header (count of unread notifications); a small + binary dot on individual catalog rows for watched RFCs with unseen + activity per §7.2. No per-row count, no per-section count. +- **Toasts** — transient, in-session signals. Used only for the + contributor's own actions completing (proposal opened, change + accepted, graduation complete) and for arrivals during the current + session of signals about work the user is *actively viewing*. + Never the channel for activity elsewhere; that is what the inbox + is for. +- **Email** — out-of-session reach. Opt-in per category per §15.4, + conservative defaults. The single channel that escapes the app. +- **Digest** — aggregation. The catch-up surface for activity on + watched RFCs the user hasn't triaged through any of the real-time + channels. Cadence and dedup contract in §15.5. + +The five compose, they do not overlap by accident. Each event is +routed to exactly the surfaces appropriate to its category, the +recipient's watch state, and the recipient's email/digest +preferences; the deduplication contract in §15.5 prevents the same +event from reaching the user twice through real-time and digest +paths. + +**Event kinds.** The `notifications.event_kind` enum captures the +signal taxonomy this section commits to. The starting set: +`proposal_merged`, `proposal_declined`, `proposal_opened_on_watched_topic`, +`pr_opened`, `pr_merged`, `pr_withdrawn`, `pr_commit_added`, +`pr_review_thread_new`, `pr_review_thread_reply`, +`pr_conflict_with_main`, `chat_message_in_participated_thread`, +`chat_reply_to_my_message`, `change_proposed_on_edited_passage`, +`flag_dropped_on_watched_rfc`, `flag_resolved_on_my_flag`, +`contribute_grant_added`, `contribute_grant_revoked`, +`branch_pinned`, `branch_auto_closed`, `super_draft_graduation_ready`, +`graduation_complete`, `graduation_rolled_back`, `rfc_withdrawn`, +`rfc_reopened`, `claim_opened`, `claim_merged`, +`permission_change_affecting_me`, `app_wide_mute_set`, +`app_wide_mute_lifted`, `digest_emitted`. The enum is extensible; the +build session adjusts as new gestures are wired in. + +### 15.2 The inbox + +The inbox is a global view, reachable from a header icon present on +every authenticated screen alongside the §14.3 About link. Selecting +it opens a center-pane overlay (or a route, `/inbox`, depending on +the build session's chrome choice — the binding part is *one* +inbox, not per-RFC inboxes). + +Each row carries: the actor's display name (or "system" attribution +for null-actor rows per §15.9), a verb phrase rendered from the event +kind plus payload, a scope chip (RFC title, branch name where +applicable, PR number where applicable), and a relative timestamp. +Clicking the row navigates to the work the signal is about and +marks the row read on next inbox refresh via the visit-advances- +cursor reconciler in §15.7. A `Mark read` affordance on each row is +available for triage without navigation. + +Above the list, three controls: a **filter chip strip** (multi- +select, AND-combined: `Unread only`, `RFC: …`, `Category: personal- +direct / structural / churn`, `From: <user>`), a **bundle toggle** +(switches between per-row and per-RFC + per-event-kind grouping — +"3 new commits on PR #4 / RFC-0042" as a single bundle row, +markable in one gesture), and a **Mark all read** action that +respects the current filter (so the user can mark all churn read +without touching personal-direct rows). + +The inbox is *not* the place watch-state preferences, email +categories, digest cadence, quiet hours, or per-user mutes are +configured — those live on a separate notification-settings surface +whose UX is the natural next topic (§19.2). The inbox is the triage +surface; the settings panel is where the underlying preferences +live. + +### 15.3 Badges, toasts, and ambient signals + +The badge in the app header reads the unread notification count +directly; it caps at 99+ visually and reads "99+" rather than +escalating into a four-digit number that contributes nothing to +triage. Tapping the badge opens the inbox; the count refreshes from +the same SSE stream that backs the inbox itself per §17, so badge +and inbox never disagree. + +The per-row catalog dot from §7.2 is binary, not numeric. Its rule: +visible on a row when the row's RFC has any unread notification for +the signed-in user *and* the user's watch state on that RFC is +`watching` or `following` (per §15.6). The dot clears the moment +the unread count for that RFC reaches zero, whether through inbox +mark-read or through in-context visit-advances-cursor reconciliation +per §15.7. + +Toasts surface in three narrow cases. First, the user's own action +completing — proposal opened, change accepted/declined, PR opened, +graduation confirmed; these are mid-session feedback for a gesture +the user just made. Second, an event firing on the *exact view the +user is currently looking at* — a commit arriving on a PR the user +is viewing, a chat reply landing in a branch chat the user has open; +these are scoped to the live view and never persist (the inbox row +still appears for the same event, but the toast carries the +"something just happened here" beat). Third, the system-author +messages per §10.6 and §8.6 are *not* toasts — they are inline chat +content; toasts are a chrome surface, not a content surface. Toasts +are dismissible by gesture and auto-dismiss after a short interval +(implementation-detail timing); the chrome never stacks more than a +small number of toasts on top of each other (a fourth arriving while +three are visible queues, not stacks). + +The contribute-mode entry in §8.3, the graduation-complete frame in +§13.3, and the proposal-merged/declined banners in §9.3 already +carry their own visual beats and are not duplicated as toasts. + +### 15.4 Email + +Email is the single channel that escapes the app. The discipline +governing it: opt-in per category with category-specific defaults, +body mirrors the inbox row text verbatim, one-click unsubscribe per +category, single non-spoofing From identity. + +Four categories, each with a distinct default and rationale: + +- **Personal-direct events — default on.** Signals where the + recipient is the named subject: proposal merged or declined (§9.3, + recipient is the proposer); graduation completed on a super-draft + the recipient owns; contribute grant added or revoked (recipient + is the grantee); reply directly to a thread the recipient posted + in; AI proposal targeted at a passage the recipient previously + edited (§8.11); the recipient's PR merged or withdrawn by someone + else. The contract: when the contributor's name is on the action, + the system reaches out of band. Opt-in here would let the contract + fail silently. +- **Watched-RFC structural events — default off.** PR opened on a + watched RFC, PR merged, graduation, withdrawal, decline-of- + someone-else's-proposal — the structural beats on RFCs the + recipient watches or follows but is not personally subject to. + Inbox and badges already carry these; the email toggle is opt-in + for contributors who want out-of-session reach. +- **Watched-RFC churn — never email.** New commits on a PR the + recipient did not open, additional chat messages in a thread the + recipient did not participate in, new flags on a watched RFC. The + email toggle for this category appears in settings as permanently + disabled, with a tooltip naming the refusal explicitly: *"Per- + commit and per-message email is intentionally not offered. The + digest aggregates this activity weekly."* Naming the refusal is + more honest than silently omitting the toggle. +- **Admin-actionable events — default on for admins/owners, + unused for contributors.** Super-draft graduation-ready (an + owner has been claimed and no body-edit PRs are blocking per + §13.2); mute/restore events affecting the recipient as the actor + or subject; permission-change events affecting the recipient. + Admin actions are time-pressured against waiting contributors. + The column on `users` is unused for contributors and ignored at + generation time. + +The email envelope: subject `[Wiggleverse] <Event>: <RFC title or +super-draft title>`. From: a single noreply identity (e.g. +`Wiggleverse <notifications@…>`), regardless of which user's gesture +produced the signal per §15.9. Body: the inbox row's rendered text +verbatim, plus a one-line context line ("by @alice on +RFC-0042 / branch <name> · 5 minutes ago"), plus one primary link to +the changed thing. No marketing footer. Two trailing links: +`Unsubscribe from <this category>` (one-click, no confirmation page; +signed URL, idempotent) and `Manage all preferences`. + +Email is held during the recipient's quiet hours per §15.8 and +released at window end; held messages bundle into a single "Activity +while you were away" email when more than a small threshold (the +build session can set, in the neighborhood of five) accumulated, +otherwise sending individually at window end. + +Bounces and complaints route to the global email opt-out path +automatically. The `users` table does not need a separate bounce +column — a global opt-out is the only durable response to a hard +bounce. + +### 15.5 The digest + +The digest is the catch-up surface for activity on watched RFCs +that the recipient hasn't triaged through any other channel. It +runs on its own cadence and carries the *churn* class that §15.4's +email policy intentionally excluded. + +Default cadence is **weekly**, fired Sunday evening UTC (most +regions wake up to it Monday morning). Per-user configurable on the +`users.digest_cadence` column to `daily`, `weekly`, or `off`. The +digest is not sent if there is nothing to report. + +Coverage is **event-window**, not time-window: each digest covers +"everything since the recipient's last digest," tracked via the +`notification_digests` row's `period_start` / `period_end`. A +recipient switching cadence — weekly to daily, or off back to +weekly — does not see overlapping coverage. + +Three exclusion rules, applied in order at digest assembly time: + +1. Exclude any notification with `email_sent_at` set — those were + already triaged out-of-session via the personal-direct or + structural email categories. The digest does not re-report things + the recipient already saw in email. +2. Exclude any notification with `read_at` set — those were + triaged in the inbox or in-context via the visit-advances- + cursor reconciler in §15.7. +3. Include all remaining notifications, but annotate any still- + unread row as "still unread in your inbox." The digest is the + catch-up surface; suppressing unread items would defeat its + purpose, but the recipient deserves the disclosure that the + item is double-tracked. + +Each included notification's `digest_included_at` is set when the +digest is emitted, making the three rules idempotent and queryable +at audit time. + +Format: subject `[Wiggleverse] Weekly digest — N events across M +RFCs` (or `Daily digest`). Body in spec voice: a single summary +sentence, then per-RFC sections ordered by activity volume, each +section grouping events by kind — "3 PRs opened on RFC-0042 (Open +Human Model): #12, #13, #14"; "5 commits on PR #4 (still open)"; +"12 new chat messages across 3 threads"; "2 flags dropped." Each +event line links to the work; the per-RFC section header links to +the RFC view. Trailing links match §15.4's email envelope: +`Manage digest preferences`, `Manage all preferences`. + +The digest does not aggregate personal-direct events (those have +their own email channel), does not include events on RFCs the user +has muted via the `watches.muted` state, and does not send to +admins for admin-actionable events (those are personal-direct). + +### 15.6 The watch / subscription model + +Three watch levels per RFC, all stored on the `watches` table per +§5: `watching`, `following`, `muted`. Each row is per (user, RFC); +absence of a row means no relationship and no signal generation. + +Watches are **implicit, earned by gestures**, with manual override +always available. The auto-rules: + +- A **substantive gesture** sets `watching` if no row exists, or + upgrades `following` → `watching` (never downgrades). The + substantive gestures: post in a chat thread; create a branch; + open a PR; accept or decline a change; claim ownership; receive + a contribute grant; drop a flag; merge a PR; resolve a thread. +- **Starring** sets `following` if no row exists. Star never + downgrades a `watching` row to `following` (the substantive + gesture wins); star never overrides an explicit `muted` (the + explicit setting wins). The star itself is independent of the + watch row — unstarring does not change the watch state. +- **Role assignment** as arbiter or owner per §6.3 implicitly + treats the RFC as `watching` for the purpose of signal + generation, regardless of `watches` row state. The role *is* + the subscription, and it never auto-decays. An owner or + arbiter can still explicitly set `muted` on a row; the explicit + mute wins over the role-implicit watch, which is intentional + (an admin should be able to step back from a specific RFC's + notifications even while keeping the authority). + +Each level's signal scope: + +- **`watching`** — the full stream for the RFC: PR open, PR merged, + PR commits, PR review-thread activity, chat messages in + participated-in threads, change-proposed-on-edited-passage, flag + drops, flag resolutions on the recipient's own flags, contribute + grants on branches the recipient has touched, structural events + (graduation, withdrawal, reopen). Plus all personal-direct + events that happen to land on this RFC. +- **`following`** — structural beats only: PR open, PR merged, + graduation, withdrawal, decline-of-others'-proposals. No commit- + level churn, no chat noise, no flag drops on threads the + recipient didn't participate in. Plus all personal-direct + events that happen to land on this RFC. +- **`muted`** — no signals from this RFC, including no personal- + direct events on this RFC. The muted state is explicit-only and + is the strongest "leave me alone about this" gesture available. + +Per-PR and per-thread signaling is **derived from existing +participation tables**, not stored on a separate `pr_watches` or +`thread_watches` table: + +- A user receives signals about a PR if they have ever advanced + `pr_seen` on it, have a row in `changes.acted_by` on its branch, + or are the PR's opener. These are the "you touched this PR" + signals — the muted state on the RFC suppresses them. +- A user receives signals about a thread if `thread_messages` + carries a row authored by them in that thread. The muted state + on the RFC suppresses them. + +This keeps the watch surface narrow (one row per user × RFC) while +preserving the precision the inbox needs. + +**90-day decay.** A `watching` row that has not accumulated a +substantive gesture from the user in 90 days auto-decays to +`following`. The `watches.last_participation_at` column tracks the +last-substantive-gesture timestamp; a nightly job downgrades rows +whose timestamp is older than the threshold. Star never decays; +explicit settings never decay; role-implicit watching never decays +(it is not stored on the row, it is computed). On the user's next +visit to the decayed RFC, a small unobtrusive line in the chrome +notes the change ("Following since [date]"); a one-click affordance +restores `watching`. + +A small **watch-state affordance** on the RFC view header lets the +user move between the three levels at any time. Explicit choices +set `set_by = 'explicit'` and are exempt from auto-decay. Manual +override always wins over auto-rules. + +### 15.7 The unread mechanism + +Two cursor families serve two different jobs, and the section is +careful not to conflate them. + +**The notifications cursor — per-event read state.** Each row in +`notifications` carries its own `read_at`. The inbox renders unread +rows accented and clears the accent on read. The unread count +backing the header badge is `COUNT(*) WHERE recipient = me AND +read_at IS NULL`. Marking a row read is per-row, per-bundle, or +per-filter (the `Mark all read` action). Per-event read state is +what the inbox needs because triage is per-event: "I read the email +about the decline; the corresponding inbox row should be marked +read; but I haven't visited the proposal page yet." + +**The scope cursors — within-scope freshness.** `pr_seen` (§10.3) +and `branch_chat_seen` (§5) both track per-user, per-scope cursors +for the in-context "what changed since my last visit" accent +inside a PR review surface or a branch chat. These are *within- +scope* cursors; they answer "have I seen the contents of *this +scope*?" rather than "have I triaged the signal that *this scope* +moved?" + +**Reconciliation between the two.** Visiting the work advances the +scope cursor (per §10.3 for PRs, per §5 for branch chats); a +post-visit reconciler then marks related notifications read on +next inbox refresh. The reconciler's rule: for each scope cursor +that just advanced, find all `notifications` rows where +`recipient_user_id = me` AND `(rfc_slug, pr_number)` or +`(rfc_slug, branch_name)` matches the advanced scope AND `read_at +IS NULL` AND the event's logical timestamp is at or before the new +cursor position. Set `read_at` for those rows. This closes the +loop in the visit-advances-triage direction; the inverse (marking +the inbox row read does not advance the scope cursor) is also +intentional, because the user may know enough from the email or +inbox text not to need to visit the work yet, and we do not want +the scope cursor falsely claiming the user has read the diff. + +Per-signal vs per-bundle read advance: the inbox renders each +notification as its own row by default; the bundle toggle from §15.2 +collapses rows by RFC + event kind, and marking a bundle read marks +all its constituent rows. Both modes operate on the same per-row +`read_at` column. + +### 15.8 Do-not-disturb + +Three mechanisms, each narrowly scoped: + +- **Quiet hours.** A single per-user window in the user's local + time, stored in `users.notification_quiet_hours_start / + _end / _timezone`. Default off (all columns null). During the + window, email and digest delivery is held; inbox and badges are + unaffected (they do not make sound). At window end, held + messages bundle into a single "Activity while you were away" + email per §15.4 if more than the bundling threshold accumulated; + otherwise they fire individually. A single window is the + schema commitment — workday quiet hours separately from + nighttime quiet hours is the kind of refinement that earns its + schema later, after evidence. +- **Per-RFC mute.** The `watches.state = 'muted'` setting from + §15.6. No separate mechanism. Suppresses all signal generation + for the (user, RFC) pair, including personal-direct events + scoped to that RFC. +- **Per-user mute.** A row in `notification_user_mutes` keyed by + (muter_user_id, muted_user_id) suppresses any notification whose + `actor_user_id` matches the muted user. Notification-volume + only; does not affect content visibility in any view — the muted + user's posts, commits, flags, and review threads remain + readable. The framework's evidence claim depends on the muted + user's words remaining visible; muting their *name* in the + recipient's inbox is the narrow tool the recipient needs. + + Per-user mute is **not available** to admins or owners exercising + app-wide authority, and not available to arbiters exercising + §6.3 authority on RFCs where they hold it. The role + contractually requires receiving signals from everyone; muting + one's way out of an arbitration responsibility undercuts the + role. The UI surfaces the constraint at the moment of attempted + mute, with the rationale inline rather than as a silent disable. + +These three are strictly orthogonal to §6.2's app-wide write-mute, +per the clarification folded into §6.2 in this section's pass. + +### 15.9 Notification authorship + +Notifications surface the **underlying user** as actor, not the +bot. The `notifications.actor_user_id` column holds the underlying +user's id for any event produced by a human gesture; the inbox row +reads "@alice opened PR #4 on RFC-0042 (Open Human Model)," and +the email subject reads "[Wiggleverse] @alice opened PR #4: +<title>." Triage requires the human's name in the noun slot — the +reader's first question is "whose work is this?" and surfacing the +bot puts the wrong noun in the way. + +**System-generated events have `actor_user_id = null`.** The auto- +close at 30 days (§11.5, §12), the digest emission, the anchor- +lost transition on threads (§8.12), the graduation-readiness +detection (§13.1), the post-graduation catalog transition (§7.2) +— none have a human actor. The inbox row names no one: "PR #4 +auto-closed (30 days idle)." Absence of an actor is the honest +signal that no human acted; the system does not invent attribution. + +**The bot account does not appear in notifications.** It remains +the §1 Git-layer persona that opens commits and PRs on behalf of +users, and §6.5's `On-behalf-of:` trailer remains the Git-side +accountability mechanism the audit log relies on. Surfacing the +bot on the notification side would conflate two distinct +accountability boundaries — the Git-layer "who opened this PR" +and the social "whose argument is this" — and the conflation +would cost the framework the precision it depends on. The same +underlying-actor-not-bot rule applies to **every other user-visible +attribution surface** — the §7.3 pending-ideas disclosure's +"by @alice," the §10.3 PR header's opener line, any in-app +rendering of "who did this." The §4 cache resolves the actor for +these surfaces by joining against the §6.5 `actions` log keyed on +`(rfc_slug, pr_number, action_kind)`, falling back to parsing the +`On-behalf-of:` trailer when the audit log row is absent (e.g. +when the cache was rebuilt from a meta repo populated outside the +app's history). The bot login surfaces only in the Git log and in +the `On-behalf-of:` trailer itself, never as a noun in the app's +prose. + +**Email envelope identity is a single non-spoofing address** — +`Wiggleverse <notifications@…>` — regardless of which user's +gesture produced the signal. Spoofing the actor's email address +creates SPF/DKIM trouble for legitimate domains and gets the +framework's mail marked spam; surfacing two competing identities +(bot in From, user in subject) breaks email-client filtering rules +that key on From. One stable envelope identity, the actor named in +content. + +--- + +## 16. What is deliberately deferred + +Calling these out so the follow-up session knows what is *not yet +specified* and what is intentionally out of scope for v1. + +- **The main document pane.** How `RFC.md` (or the super-draft body) + renders, the Tiptap editor configuration, how the editor handles + rich markdown, headings, links, code blocks. +- **The per-RFC and per-branch chat UX.** Threading model, AI + participation, the discuss-vs-contribute mode distinction from the + prototype, the selection tooltip, the prompt bar, model picker. +- **The revision flow.** How proposed changes from AI or contributors + appear, the change-card panel, accept/decline/edit, tracked + insertions/deletions in the editor. +- **Body full-text search.** Title/slug/ID/tag search ships in v1; + body content search is a later layer. +- **Featured super-drafts.** A `featured: true` frontmatter flag for + owner/admin curation. One-line addition we can ship when the need + is real. +- **Accepted / deprecated states.** Reintroducible if an OHM moment + ("this is now official") becomes desirable. +- **Raw `git clone` + push as a contribution path.** All contribution + flows through the app. +- **Self-merge window for un-acted ownership claims.** Configurable; + off in v1. + +--- + +## 17. Backend surfaces (API shape, illustrative) + +The follow-up session will refine this. A minimal starting set: + +- `GET /api/rfcs` — list entries with state, id, title, slug, repo, + owners, last_active_at, has_open_prs, starred-by-me. Supports + search, sort, filter chips, and the `unclaimed` predicate. +- `GET /api/rfcs/<slug>` — one entry's full metadata plus tree + view (open PRs, open branches, closed branches if requested). +- `GET /api/rfcs/<slug>/main` — main-branch body. +- `GET /api/rfcs/<slug>/branches/<branch>` — branch body and metadata. +- `POST /api/rfcs/propose` — open the idea-submission PR per §9.1. + Body carries `title`, `slug`, `pitch`, `tags`. Server-validates + slug uniqueness against `rfcs/` on the meta-repo main and against + the slugs of any open idea PRs. Returns the new PR number plus a + redirect handle for the pending-idea view (§9.3). +- `GET /api/proposals` — list open idea PRs for the pending-ideas + disclosure in §7.3. +- `GET /api/proposals/<pr_number>` — pending-idea view data per + §9.3: the proposed file's body and frontmatter, the PR title and + description, the chat thread id, the viewer's available + affordances. +- `POST /api/proposals/<pr_number>/withdraw` — proposer-only; + closes the meta-repo PR per §9.3. +- `POST /api/proposals/<pr_number>/decline` — owner/admin only; + closes the meta-repo PR with a required comment per §9.3. +- `POST /api/proposals/<pr_number>/merge` — owner/admin only; + merges the meta-repo PR, creating the super-draft entry on main. + Threads on the pending idea become the super-draft's main-chat + threads per §9.3 with no data movement. +- `POST /api/rfcs/<slug>/claim` — open the ownership-claim PR per + §13.1. +- `POST /api/rfcs/<slug>/metadata` — title or tag edits on a + super-draft per §9.5's metadata pane; opens a small meta-repo PR + via the bot. Slug renames not supported in v1. +- `POST /api/rfcs/<slug>/start-edit-branch` — the "Start + Contributing" gesture on a super-draft per §9.5; cuts a fresh + meta-repo branch `edit/<slug>/<auto-name>`, returns the branch's + metadata, lands the contributor in contribute mode. The body + optionally carries a desired branch name; otherwise auto-generated. +- `POST /api/rfcs/<slug>/graduate` — run the graduation sequence + (admin/owner only). Body carries the dialog's three fields — + candidate integer ID, candidate repo name, initial owners. Returns + a stream handle for the progress SSE below; the body fields are + re-validated atomically server-side at the top of the sequence + (the repo-name collision check in particular) since a concurrent + graduation could land between dialog-open and confirm. +- `GET /api/rfcs/<slug>/graduate/check` — inline validation for the + Graduate dialog per §13.2. Query params: `id` (the candidate + integer ID), `repo` (the candidate repo name). Returns per-field + collision/validity status from the catalog cache plus a + server-authoritative repo-name collision check. Debounced from + the client; the dialog calls this as the admin types. +- `GET /api/rfcs/<slug>/graduate/progress` — SSE stream of the + five-step transactional sequence per §13.3, one event per step + transition (`pending` → `running` → `done` / `failed`), plus a + trailing `rollback` step's events if any earlier step fails. The + Graduate dialog opens this stream on confirm and renders the step + stack from the events. The stream closes on success or on + rollback completion. +- `GET /api/rfcs/<slug>/blocking-prs` — list open meta-repo PRs + against `rfcs/<slug>.md` per §13.2's precondition popover. Returns + PR number, title, author, last-activity timestamp, and the + viewer's available actions (merge, withdraw, open-in-new-tab) per + §6.1 / §6.3. +- `POST /api/rfcs/<slug>/withdraw` — withdraw an entry. +- `POST /api/rfcs/<slug>/branches/<branch>/visibility` — set + read_public and contribute_mode. +- `POST /api/rfcs/<slug>/branches/<branch>/grants` — add/remove + per-branch contribute grants. +- `POST /api/rfcs/<slug>/branches/main/promote-to-branch` — the + `Start Contributing` flow on main per §8.14: cuts a new branch + from main's tip, re-anchors pending `changes` rows from `main` to + the new branch's name, returns the new branch's metadata. Body + optionally carries a desired branch name; otherwise an + auto-generated value is used. +- `POST /api/rfcs/<slug>/branches/<branch>/changes/<change_id>/accept` + — accept a pending change per §8.9; runs the immediate commit per + §8.6. Body carries the possibly-edited `proposed` text, the + `was_edited_before_accept` flag, and an optional + `force_apply_stale` flag for the stale-change confirmation path + per §8.11. +- `POST /api/rfcs/<slug>/branches/<branch>/changes/<change_id>/decline` + — decline per §8.9. No commit; row state moves to `declined` and + persists as evidence. +- `POST /api/rfcs/<slug>/branches/<branch>/changes/<change_id>/reask` + — regenerate a stale AI proposal against current text per §8.11. + Triggers a fresh streaming assistant message in the originating + thread; the old row stays for audit. +- `POST /api/rfcs/<slug>/branches/<branch>/manual-flush` — explicit + save gesture for buffered manual edits per §8.11; the API + equivalent of the in-card `Save now` button. Idempotent for an + empty buffer. +- `GET /api/rfcs/<slug>/branches/<branch>/threads` — list threads on + the branch with filtering by `state` and `thread_kind`. Includes + the whole-doc default thread. +- `POST /api/rfcs/<slug>/branches/<branch>/threads` — create a thread + per §8.12 or §8.13. Body: `thread_kind`, `anchor_kind`, + `anchor_payload`, `label` (required for `flag`, optional for + `chat`), and an optional first `message` for chat-kind. +- `POST /api/rfcs/<slug>/branches/<branch>/threads/<thread_id>/messages` + — post a message into a thread per §8.12; the same endpoint is the + reply path. Body: `text`, optional `quote`. +- `POST /api/rfcs/<slug>/branches/<branch>/threads/<thread_id>/resolve` + — resolve a thread per §8.12; permission per the rules in that + section. +- `POST /api/rfcs/<slug>/branches/<branch>/open-pr` — open a PR per + §10.1; body carries the AI-drafted (and possibly edited) title and + description. +- `GET /api/rfcs/<slug>/prs/<pr_number>` — PR metadata, diff handle, + thread ids for the conversation and review surfaces. +- `POST /api/rfcs/<slug>/prs/<pr_number>/seen` — advance the per-user + seen-cursor (§10.3). +- `POST /api/rfcs/<slug>/prs/<pr_number>/review` — post a review-kind + thread anchored to a diff range per §10.4. +- `POST /api/rfcs/<slug>/prs/<pr_number>/merge` — merge per §10.5 + (arbiter/admin/owner only). +- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8. +- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a + fresh resolution branch and replay per §10.9. +- `POST /api/admin/users/<id>/role` — set role (owner/admin only). +- `POST /api/admin/users/<id>/mute` — mute/unmute (the §6.2 app-wide + write-mute, not the §15.8 notification mutes). +- `POST /api/stars/<slug>` — star/unstar. +- `POST /api/webhooks/gitea` — webhook receiver. +- `GET /api/notifications` — list inbox rows for the signed-in user, + per §15.2. Query params: `unread` (bool), `rfc_slug`, `category` + (`personal-direct` | `structural` | `churn`), `actor_user_id`, + `bundled` (bool — server-side grouping by RFC + event_kind per + §15.2). Returns row payload plus the unread count for the badge. +- `POST /api/notifications/<id>/read` — mark a single row read. +- `POST /api/notifications/read` — mark read by filter; body carries + the same filter params as the list endpoint. Used by `Mark all + read` and by bundle-collapse `mark read` actions per §15.2. +- `GET /api/notifications/stream` — SSE stream of inbox additions + and read-state changes for the signed-in user. Backs the live + inbox refresh and the header badge counter per §15.3. +- `GET /api/watches` — list the signed-in user's watch rows per + §15.6, with per-row state and `set_by`. +- `POST /api/rfcs/<slug>/watch` — set watch state explicitly per + §15.6; body carries `state` (`watching` | `following` | `muted`). + Sets `set_by = 'explicit'` and exempts the row from the 90-day + auto-decay. +- `POST /api/rfcs/<slug>/branches/<branch>/chat-seen` — advance the + per-user `branch_chat_seen` cursor per §5 / §15.7; body carries + `last_seen_message_id`. Triggers the inbox reconciler for + chat-kind notifications scoped to this branch. +- `GET /api/users/me/notification-preferences` — read the per-user + email category toggles and digest cadence per §15.4 / §15.5. +- `POST /api/users/me/notification-preferences` — set them. +- `GET /api/users/me/quiet-hours` — read the per-user quiet-hours + window per §15.8. +- `POST /api/users/me/quiet-hours` — set or clear it; body carries + start, end, timezone (all required to set; all null to clear). +- `POST /api/users/<id>/notification-mute` — add a per-user + notification mute per §15.8. Idempotent. Refused if the signed-in + user is acting in an admin/owner authority capacity or as an + arbiter on an RFC where the muted user is also active. +- `DELETE /api/users/<id>/notification-mute` — remove it. +- `GET /api/email/unsubscribe` — one-click per-category + unsubscribe per §15.4. Signed URL; idempotent; redirects to a + short confirmation page. +- `POST /api/webhooks/email-bounce` — bounce and complaint receiver + per §15.4; sets the recipient's global email opt-out. + +Plus all the chat / streaming / model-picker endpoints inherited from +the prototype, scoped to per-RFC and per-branch threads. + +The `branches/<branch>/...` endpoint family covers both per-RFC-repo +branches and meta-repo edit branches; per §9.5, the routing collapses +on a single rule — when `<slug>` resolves to an entry in state +`super-draft`, `<branch>` names a branch on the meta repo. The +endpoint implementations dispatch on the entry's state to pick the +right Gitea repo; the API surface is the same. + +--- + +## 18. Carryover from the prototype + +These are confirmed unchanged from the existing app and should be +preserved as-is in the rewrite unless a downstream decision changes +them: + +- FastAPI + SSE for streaming chat. +- React + Vite + Tiptap (ProseMirror) for the editor. +- The structured `<change>` / `<original>` / `<proposed>` / `<reason>` + protocol for AI-proposed edits. +- Multi-provider LLM support: Anthropic Claude, Google Gemini, OpenAI / + GitHub Copilot. `ENABLED_MODELS` and per-provider API keys via env. +- The discuss-vs-contribute distinction inside an RFC view (to be + fully specified in the follow-up session). +- Gitea OAuth for user authentication. The OAuth identity is the basis + for the app's user account; authorization is layered on top per §6. + +--- + +## 19. Topics still to settle + +This spec captured the topics settled across thirteen sessions — +the structural model (repos, states, storage, permissions, list), +the RFC view (document, chat, branches, changes, threads, flags), +the super-draft view and lifecycle, the PR flow, the graduation +flow, the chrome (landing, philosophy), and now the notification +surface. With Topic 13 folded in, the structural surface is +complete. What follows is no longer "topics that block specifying +v1" but "topics to address during or shortly after the v1 build." + +### 19.1 Next slice: the active-RFC view in full + +Slice 1 of the build has landed. The repository scaffolding +(`backend/`, `frontend/`, `scripts/`, `docs/`) is in place; the §5 +canonical app tables exist as numbered SQLite migrations with the +§4 cache mirror beside them; the §1 bot wrapper is the single +chokepoint every Git write flows through, with the §6.5 +`On-behalf-of:` trailer applied uniformly and an `actions` row +recorded; Gitea OAuth provisions a `users` row on first sign-in +with role resolved from `OWNER_GITEA_LOGIN`; the §4.1 webhook +receiver and the periodic reconciler both write to the cache and +neither user actions nor the API do; the §7 left pane (catalog +with search, sort, state-filter chips, pending-ideas disclosure, +"+ Propose New RFC" button) renders against `GET /api/rfcs` and +`GET /api/proposals`; and the end-to-end propose-to-super-draft +vertical works: propose modal opens the idea PR, owner merges from +the pending-idea view, webhook (or reconciler sweep) updates the +cache, the catalog crossfades the super-draft in, and the +super-draft view renders the body. The vertical is covered by +integration tests against an in-process Gitea simulator. + +Several §9 affordances that depend on infrastructure that has not +yet been built were deferred from Slice 1 to Slice 2 — they are +not new candidate topics, only delivery sequencing: + +- The §9.1 propose modal's AI-suggested tags and the §9.2 + AI-drafted PR description — the AI surface lands with chat. +- The §9.3 two-step composer-then-preview decline dialog — + shipped as a single-step required-comment input in Slice 1, with + the preview-and-confirm ceremony pulled into the existing §19.2 + "pending-idea view's interaction design (remainder)" topic + alongside the merge-confirmation ceremony. +- The §9.3 pre-merge chat thread on a pending-idea view and its + migration to the super-draft on merge — depends on the per-RFC + / per-branch chat infrastructure Slice 2 builds. + +**Slice 2 is the active-RFC view per §8 in full.** The view +inherits the three-column shape (§8.1), opens on `main` in +discuss mode by default (§8.2), supports the §8.3 +discuss-vs-contribute mode flip on non-main branches, hosts §8.4's +per-branch chat with AI participation (the §18 `<change>` +protocol parsing into `changes` rows per §8.6), the §8.8 +change-card panel with §8.9's accept / decline / +edit-before-accept resolution, the §8.10 tracked-change markup +and DiffView toggle, the §8.11 manual-edit flushes, the §8.12 +range and paragraph sub-threads, the §8.13 flag affordance, and +the §8.14 discuss-mode buffer. The carryover assets — the Tiptap +configuration, the SelectionTooltip, the `<change>` parser, the +prompt-bar selection-quote machinery, the multi-provider LLM +abstraction, the SSE streaming — are present in working form in +the prototype at +`/Users/benstull/projects/wiggleverse/rfc-app-prototype/` and +should be lifted directly per §18. + +The next build session should read `SPEC.md`, `README.md`, and +`docs/DEV.md` and pick up Slice 2 cleanly without re-briefing. +The working agreement in §19.3 carries forward: implement the +slice, correct the spec only where running code reveals it was +wrong at a structural level, accumulate new candidate topics in +§19.2, do not extend the spec beyond what the slice requires. + +### 19.2 Candidate topics for sessions after the v1 build lands + +These remain unsettled and will earn their own sessions as the +build surfaces evidence for which one matters next. Topics are +listed roughly in order of expected weight; the order is not +binding. + +- **Per-RFC model availability and credential delegation.** Which + AI models contributors can pick from when chatting on a given + RFC, and who supplies the API resources for those models. + Replaces §18's app-level `ENABLED_MODELS` and env-supplied keys + with per-RFC-scoped configuration. Touches every AI surface — + every chat thread (§8.12), every change-proposal turn (§8.9, + §8.11), every flag-resolution invocation (§8.13), the AI-drafted + PR title and description (§10.2), and the propose modal's + AI-suggested tags (§9.1). Touches §5 (schema for model config + and credentials), §6 (possibly a `funder` role, or a per-RFC + capability extension along the lines of §6.2's per-user + overrides), and §18 (carryover supersession). Subdividable into + "model availability" (lighter, UX-shaped) and "credential + delegation and the funder role" (heavier — security, billing, + abuse mitigation, rotation, mid-conversation key failure) if the + session driver judges the combined scope too large. Load-bearing + once the framework runs past single-operator deployment; + defer-able until then. +- **Admin surfaces.** Where role management, muting, audit-log + views, the graduation-readiness queue, and Topic 13's + notification-preferences settings (email categories per §15.4, + digest cadence per §15.5, quiet hours per §15.8, per-user mute + list per §15.8, watch-state overview per §15.6) live in the + chrome. Topics 12 and 13 both expanded the admin's repertoire + without giving it a centralized home base; consolidating it is + the natural next move once the build is on its feet. +- **The notification settings UI.** Topic 13 settled the schema + and the per-category rules; the surface where a contributor + finds the per-category email toggles, digest cadence, quiet + hours config, watch states, and per-user mute list is the + natural follow-on. Likely overlaps the admin-surfaces topic for + admins and stands alone for contributors. Small-to-medium scope. +- **The conflict-replay UX in detail.** §10.9 commits to a + resolution-branch path where the AI participant replays the diff + onto fresh main, with manual fallback for ambiguous conflicts. + The resolution chat surface, the manual-resolve gesture, and how + the AI's replay attempts surface in the conversation are + unspecified. Narrow and concrete; defer-able until conflicts + happen often enough in real use to design against. +- **The pending-idea view's interaction design (remainder).** Topic + 12 settled the decline ceremony per §9.3 (two-step composer- + then-preview dialog, "Propose a revised entry" affordance for + the declined proposer). The merge-confirmation ceremony and the + inline render of the meta-repo PR's diff against the catalog + remain unspecified and could earn their own topic. +- **The metadata pane UX.** §9.5 lands the metadata pane as a + structural commitment for super-draft title and tag edits. The + interaction design — when the pane opens, how multiple edits + queue into a single meta-repo PR, how AI assists tag editing — + is its own small UI topic. +- **The public face of discuss mode.** §8.14 settles what a + contributor sees in discuss mode on a branch with buffered + proposals. An anonymous reader or muted contributor on the same + branch is not yet specified — what they see of the pending + count, the preview disclosure, and the `Start Contributing` + invitation (which they can't act on) is its own small but + distinct topic. +- **Super-draft slug renames.** §9.5 defers renames on the basis + that a slug rename is a file rename in Git plus a cache-key + rewrite, rare enough to skip in v1. If usage shows real demand + — e.g., a super-draft whose framing shifted enough that the + original slug misleads — this earns its own topic, settling the + file-rename bot sequence, the redirect handling for any links + into the old slug, and the cache and threads migration. +- **Persistent accepted-change markup for returning contributors.** + §8.10 commits the editor's tracked-change markup to session- + local scope and points returning-contributor needs at DiffView. + A future session may revisit this with a per-user, per-branch + seen-cursor for accepted changes (mirroring §10.3's PR seen- + cursor) — markup persisting across reloads, dismissible with a + "mark as seen" gesture. Triggered by evidence of contributors + asking for it, not ahead of evidence. +- **AI participation as a notification source.** Topic 13 settled + that user-driven events carry the underlying user as actor and + system-generated events carry null. AI participant completions + (long-running change generation in a thread, an `Ask Claude to + propose a fix` invocation per §8.13 that returns minutes later) + are a third case the section did not explicitly settle — the + build will reveal whether they fire notifications at all, + whether they carry a synthetic "Claude" actor or fall under + null-system, and how the bot-vs-user distinction in §15.9 + extends. Will surface during build if AI turn-times grow large + enough to warrant. +- **Cache bootstrap from a pre-existing meta repo.** §4.1 covers + steady-state cache freshness — the webhook is the fast path, the + reconciler the safety net — but assumes the cache grew up + alongside the bot. If the cache is rebuilt from scratch against + a meta repo that has history the bot did not author (a + transferred meta repo, a disaster-recovery rebuild after the app + database is lost), the reconciler has no `actions` rows to join + against for the §15.9 underlying-actor-not-bot resolution. The + Slice 1 build chose a fallback chain — audit log first, then + `On-behalf-of:` trailer parsing on the commit/PR body, then the + raw Gitea login — that is good enough for v1 but earns its own + topic once the cost of "the cache thinks the bot proposed + everything pre-app" becomes concrete. Touches §4.1 (the + reconciler's job description) and §15.9 (the attribution rule). +- **Body full-text search.** When the time comes. + +Topic 13 (notifications) is settled and folded into §5 (the +notifications, watches, branch_chat_seen, notification_user_mutes, +notification_digests tables and the per-user notification +preference columns on users), §6.2 (the orthogonality clarification +against §15.8's notification mutes), §7.2 (the per-row catalog dot +for watched RFCs with unseen activity), §9.3 (the personal-direct +email channel for proposal-merged and proposal-declined events), +the new §15 (Notifications, in full), and §17 (the notification +endpoints — list, mark-read, stream, watch mutation, preferences, +quiet-hours, per-user mute, unsubscribe, bounce webhook). + +### 19.3 Working agreement for the queue + +Pre-build sessions ran on the queue agreement from prior versions +of this section: pick the next topic from §19.1, drive it to +decision, fold it in, update §19.1 and §19.2, hand off. The build +session and any sessions after it run on a modified shape: + +1. The build session implements a slice of the spec. The spec is + the source of truth; the build does not extend it. +2. Where running code reveals the spec was wrong, the build + session corrects the spec in the appropriate numbered section + with a brief note explaining what running code revealed. +3. New design questions surfaced during the build that are not + resolvable as implementation details accumulate in §19.2 as + new candidate topics, in the same shape as the existing + entries. +4. Sessions after the build pick from §19.2 by user choice, run + on the original queue agreement (drive to decision, fold, + update §19.2), and need not be sequential — multiple §19.2 + topics can be settled in any order the user prefers. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..58b7202 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,43 @@ +# --- Gitea --- +# Base URL of the Gitea instance the app speaks to. +GITEA_URL=http://localhost:3000 + +# The bot service account that performs every Git operation per §1. +# Provision a real Gitea user, generate a personal access token with +# repo and admin (or at minimum: repo, write:repository) scopes, and +# put the token here. The bot is the only Git writer. +GITEA_BOT_USER=rfc-bot +GITEA_BOT_TOKEN= + +# The Gitea org or user that owns the meta repo and every RFC repo +# the bot will create on graduation. +GITEA_ORG=wiggleverse +META_REPO=meta + +# --- OAuth (Gitea) --- +# In Gitea: Site Administration → Applications → Add OAuth2 Application. +# Redirect URI: {APP_URL}/auth/callback +OAUTH_CLIENT_ID= +OAUTH_CLIENT_SECRET= + +# --- App --- +APP_URL=http://localhost:8000 +SECRET_KEY=change-me-to-a-long-random-string +DATABASE_PATH=data/rfc-app.db + +# Per §1: owner zero. The Gitea login that gets the owner role on +# first sign-in. +OWNER_GITEA_LOGIN=ben + +# Webhook signature secret. Gitea sends X-Gitea-Signature as the +# HMAC-SHA256 of the body using this secret. Per §4.1 the webhook is +# one of two cache writers; signing keeps spurious writes out. +GITEA_WEBHOOK_SECRET=change-me-to-a-shared-secret + +# --- LLM providers (carryover §18) --- +# Comma-separated list of provider keys to enable. Per the §19.2 +# per-RFC-model topic, this is app-wide until that topic lands. +ENABLED_MODELS=claude +ANTHROPIC_API_KEY= +GOOGLE_API_KEY= +OPENAI_API_KEY= diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api.py b/backend/app/api.py new file mode 100644 index 0000000..3ad419d --- /dev/null +++ b/backend/app/api.py @@ -0,0 +1,396 @@ +"""API surface for Slice 1. + +Carries the §17 endpoints exercised by the propose-to-super-draft +vertical, plus the catalog read endpoints (§7) and the super-draft view +read endpoints (§9.4). The rest of §17 lands in the relevant later +slices; the dispatch shape here leaves room for them. + +Routing follows the §17 layout literally — `/api/rfcs`, +`/api/proposals/<n>`, etc. — so the next slice can extend the same +modules rather than untangling a layout that drifted. +""" +from __future__ import annotations + +import json +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from . import auth, db, entry as entry_mod, cache +from .bot import Bot +from .config import Config +from .gitea import Gitea, GiteaError + + +class ProposeBody(BaseModel): + title: str = Field(min_length=1, max_length=200) + slug: str = Field(min_length=1, max_length=80) + pitch: str = Field(min_length=1) + tags: list[str] = Field(default_factory=list) + + +class DeclineBody(BaseModel): + comment: str = Field(min_length=1, max_length=4000) + + +def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: + router = APIRouter() + + # --------------------------------------------------------------- + # Auth surface — extends the prototype's pattern but reads role + # from our users table per §6. + # --------------------------------------------------------------- + + @router.get("/api/auth/me") + async def auth_me(request: Request) -> dict[str, Any]: + user = auth.current_user(request) + if user is None: + return {"authenticated": False, "user": None} + return { + "authenticated": True, + "user": { + "id": user.user_id, + "gitea_login": user.gitea_login, + "display_name": user.display_name, + "email": user.email, + "avatar_url": user.avatar_url, + "role": user.role, + }, + } + + # --------------------------------------------------------------- + # §7: the catalog + # --------------------------------------------------------------- + + @router.get("/api/rfcs") + async def list_rfcs(request: Request) -> dict[str, Any]: + """§7's left pane data. + + The chip-filter / sort / search combinatorics live on the + client — the server returns the full set and lets the chips + narrow it. The set is small (hundreds, not thousands) for the + foreseeable future, so paginating here would buy nothing. + """ + viewer = auth.current_user(request) + viewer_id = viewer.user_id if viewer else None + rows = db.conn().execute( + """ + SELECT slug, title, state, rfc_id, repo, + owners_json, arbiters_json, tags_json, + last_main_commit_at, last_entry_commit_at, updated_at + FROM cached_rfcs + WHERE state IN ('super-draft', 'active') + ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC + """ + ).fetchall() + + starred = set() + if viewer_id is not None: + starred = { + r["rfc_slug"] + for r in db.conn().execute( + "SELECT rfc_slug FROM stars WHERE user_id = ?", (viewer_id,) + ) + } + + items = [] + for r in rows: + items.append( + { + "slug": r["slug"], + "title": r["title"], + "state": r["state"], + "id": r["rfc_id"], + "repo": r["repo"], + "owners": json.loads(r["owners_json"] or "[]"), + "arbiters": json.loads(r["arbiters_json"] or "[]"), + "tags": json.loads(r["tags_json"] or "[]"), + "last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"], + "starred_by_me": r["slug"] in starred, + "has_open_prs": False, # wired in Slice 2 when per-RFC repos exist + } + ) + return {"items": items} + + @router.get("/api/rfcs/{slug}") + async def get_rfc(slug: str) -> dict[str, Any]: + row = db.conn().execute( + "SELECT * FROM cached_rfcs WHERE slug = ?", (slug,) + ).fetchone() + if row is None: + raise HTTPException(404, "Not found") + return _serialize_rfc(row) + + # --------------------------------------------------------------- + # §7.3 / §9.3: pending ideas + # --------------------------------------------------------------- + + @router.get("/api/proposals") + async def list_proposals() -> dict[str, Any]: + rows = db.conn().execute( + """ + SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state + FROM cached_prs + WHERE pr_kind = 'idea' AND state = 'open' + ORDER BY opened_at DESC + """ + ).fetchall() + return { + "items": [ + { + "slug": r["rfc_slug"], + "pr_number": r["pr_number"], + "title": r["title"], + "description": r["description"], + "opened_by": r["opened_by"], + "opened_at": r["opened_at"], + } + for r in rows + ] + } + + @router.get("/api/proposals/{pr_number}") + async def get_proposal(pr_number: int, request: Request) -> dict[str, Any]: + """§9.3 pending-idea view data. + + Reads the proposed file from the proposer's branch on the meta + repo, so the viewer sees the entry as it will land. The chat + thread is not yet implemented; thread_id surfaces as null until + Slice 2's chat wiring lands. + """ + row = db.conn().execute( + """ + SELECT * FROM cached_prs + WHERE pr_kind = 'idea' AND pr_number = ? + """, + (pr_number,), + ).fetchone() + if row is None: + raise HTTPException(404, "Not a proposal PR") + # Read the proposed entry file from the head branch. + slug = row["rfc_slug"] + head = row["head_branch"] + result = await gitea.read_file(config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref=head) + entry_payload: dict[str, Any] | None = None + if result: + text, _sha = result + try: + entry = entry_mod.parse(text) + entry_payload = _entry_payload(entry) + except Exception: + entry_payload = None + + viewer = auth.current_user(request) + affordances = _proposal_affordances(viewer, row) + return { + "slug": slug, + "pr_number": pr_number, + "title": row["title"], + "description": row["description"], + "state": row["state"], + "opened_by": row["opened_by"], + "opened_at": row["opened_at"], + "entry": entry_payload, + "affordances": affordances, + } + + # --------------------------------------------------------------- + # §9.1: propose a new RFC + # --------------------------------------------------------------- + + @router.post("/api/rfcs/propose") + async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]: + user = auth.require_contributor(request) + slug = payload.slug.strip().lower() + if not entry_mod.is_valid_slug(slug): + raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes") + + # §9.1 uniqueness — against rfcs/ on main *and* against open idea PRs. + # We re-check atomically here even though the client also checks + # on every keystroke, since a concurrent submission could land + # between dialog-open and submit. + clash = db.conn().execute( + "SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,) + ).fetchone() + if clash: + raise HTTPException(409, f"Slug `{slug}` is already taken") + idea_clash = db.conn().execute( + "SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' AND rfc_slug = ?", + (slug,), + ).fetchone() + if idea_clash: + raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal") + + entry = entry_mod.Entry( + slug=slug, + title=payload.title.strip(), + state="super-draft", + id=None, + repo=None, + proposed_by=user.email or user.gitea_login, + proposed_at=entry_mod.today(), + graduated_at=None, + graduated_by=None, + owners=[], + arbiters=[], + tags=[t.strip() for t in payload.tags if t.strip()], + body=payload.pitch.strip() + "\n", + ) + contents = entry_mod.serialize(entry) + pr_title = f"Propose: {entry.title}" + # Slice 1's AI-drafted PR description is deferred — the v1 spec + # calls for it (§9.2) but the wiring belongs with the rest of + # the AI-on-the-propose-modal work; for now we send the pitch. + pr_description = ( + f"**Topic:** {entry.title}\n\n" + f"{payload.pitch.strip()}" + ) + try: + pr = await bot.open_idea_pr( + user.as_actor(), + org=config.gitea_org, + meta_repo=config.meta_repo, + slug=slug, + file_contents=contents, + pr_title=pr_title, + pr_description=pr_description, + ) + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + + # Refresh the meta-PRs cache so the proposer sees the new entry + # on the pending-ideas disclosure immediately, without waiting + # for the webhook to arrive. (The webhook will arrive too; the + # cache write is idempotent.) + await cache.refresh_meta_pulls(config, gitea) + + return {"pr_number": pr["number"], "slug": slug} + + # --------------------------------------------------------------- + # §9.3: merge / decline / withdraw an idea PR + # --------------------------------------------------------------- + + @router.post("/api/proposals/{pr_number}/merge") + async def merge_proposal(pr_number: int, request: Request) -> dict[str, Any]: + user = auth.require_admin(request) + row = _require_open_idea_pr(pr_number) + try: + await bot.merge_idea_pr( + user.as_actor(), + org=config.gitea_org, + meta_repo=config.meta_repo, + pr_number=pr_number, + slug=row["rfc_slug"], + ) + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + # Refresh both surfaces — the entry is now on main, and the PR + # is now closed. + await cache.refresh_meta_repo(config, gitea) + await cache.refresh_meta_pulls(config, gitea) + return {"ok": True, "slug": row["rfc_slug"]} + + @router.post("/api/proposals/{pr_number}/decline") + async def decline_proposal(pr_number: int, body: DeclineBody, request: Request) -> dict[str, Any]: + user = auth.require_admin(request) + row = _require_open_idea_pr(pr_number) + try: + await bot.decline_idea_pr( + user.as_actor(), + org=config.gitea_org, + meta_repo=config.meta_repo, + pr_number=pr_number, + slug=row["rfc_slug"], + comment=body.comment, + ) + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + await cache.refresh_meta_pulls(config, gitea) + return {"ok": True} + + @router.post("/api/proposals/{pr_number}/withdraw") + async def withdraw_proposal(pr_number: int, request: Request) -> dict[str, Any]: + user = auth.require_contributor(request) + row = _require_open_idea_pr(pr_number) + # Only the proposer can withdraw their own proposal, except that + # owner/admin can also act (they have all contributor powers per + # §6.1, and the withdraw path here doesn't expose decline-only + # affordances). + if row["opened_by"] != user.gitea_login and user.role not in ("owner", "admin"): + raise HTTPException(403, "Only the proposer can withdraw") + try: + await bot.withdraw_idea_pr( + user.as_actor(), + org=config.gitea_org, + meta_repo=config.meta_repo, + pr_number=pr_number, + slug=row["rfc_slug"], + ) + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + await cache.refresh_meta_pulls(config, gitea) + return {"ok": True} + + # --------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------- + + def _require_open_idea_pr(pr_number: int): + row = db.conn().execute( + """ + SELECT * FROM cached_prs + WHERE pr_kind = 'idea' AND pr_number = ? AND state = 'open' + """, + (pr_number,), + ).fetchone() + if row is None: + raise HTTPException(404, "Not an open proposal PR") + return row + + return router + + +def _serialize_rfc(row) -> dict[str, Any]: + return { + "slug": row["slug"], + "title": row["title"], + "state": row["state"], + "id": row["rfc_id"], + "repo": row["repo"], + "proposed_by": row["proposed_by"], + "proposed_at": row["proposed_at"], + "graduated_at": row["graduated_at"], + "graduated_by": row["graduated_by"], + "owners": json.loads(row["owners_json"] or "[]"), + "arbiters": json.loads(row["arbiters_json"] or "[]"), + "tags": json.loads(row["tags_json"] or "[]"), + "body": row["body"] or "", + } + + +def _entry_payload(entry: entry_mod.Entry) -> dict[str, Any]: + return { + "slug": entry.slug, + "title": entry.title, + "state": entry.state, + "id": entry.id, + "repo": entry.repo, + "proposed_by": entry.proposed_by, + "proposed_at": entry.proposed_at, + "owners": entry.owners, + "arbiters": entry.arbiters, + "tags": entry.tags, + "body": entry.body, + } + + +def _proposal_affordances(viewer, row) -> dict[str, bool]: + """§9.3 header strip affordances by role.""" + is_owner_admin = viewer is not None and viewer.role in ("owner", "admin") + is_proposer = viewer is not None and row["opened_by"] == viewer.gitea_login + return { + "merge": is_owner_admin, + "decline": is_owner_admin, + "withdraw": is_proposer or is_owner_admin, + } diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..e5fc4fb --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,195 @@ +"""Gitea OAuth and user provisioning. + +OAuth identity is the basis for the app's user account per §18; the §6 +authorization layer is built on top by reading from the users table. On +first sign-in we insert a row with role='contributor' (or 'owner' if the +gitea_login matches the configured OWNER_GITEA_LOGIN — bootstrapping for +owner zero per §6.1). On subsequent sign-ins we refresh the display name +and avatar from Gitea so a rename in Gitea propagates here. +""" +from __future__ import annotations + +import secrets +from dataclasses import dataclass +from typing import Any + +import httpx +from fastapi import HTTPException, Request + +from . import db +from .bot import Actor +from .config import Config + + +@dataclass +class SessionUser: + user_id: int + gitea_id: int + gitea_login: str + display_name: str + email: str + avatar_url: str + role: str + + def as_actor(self) -> Actor: + return Actor( + user_id=self.user_id, + gitea_login=self.gitea_login, + display_name=self.display_name, + email=self.email, + ) + + +def authorization_url(config: Config, state: str) -> str: + return ( + f"{config.gitea_url}/login/oauth/authorize" + f"?client_id={config.oauth_client_id}" + f"&redirect_uri={config.redirect_uri}" + f"&response_type=code" + f"&state={state}" + ) + + +async def exchange_code(config: Config, code: str) -> dict[str, Any]: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{config.gitea_url}/login/oauth/access_token", + json={ + "client_id": config.oauth_client_id, + "client_secret": config.oauth_client_secret, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": config.redirect_uri, + }, + headers={"Accept": "application/json"}, + ) + resp.raise_for_status() + return resp.json() + + +async def fetch_user_profile(config: Config, access_token: str) -> dict[str, Any]: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.get( + f"{config.gitea_url}/api/v1/user", + headers={"Authorization": f"token {access_token}"}, + ) + resp.raise_for_status() + return resp.json() + + +def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser: + """Insert or update the users row for this Gitea profile. + + Owner zero (§6.1) is whoever's gitea_login matches OWNER_GITEA_LOGIN. + The owner role is granted on first sign-in and never revoked from a + later config change — once owner, always owner until an explicit + role transition (which lives in §6.1 and isn't part of slice 1). + """ + gitea_id = profile["id"] + login = profile["login"] + display = profile.get("full_name") or login + email = profile.get("email") or "" + avatar = profile.get("avatar_url") or "" + + c = db.conn() + existing = c.execute("SELECT * FROM users WHERE gitea_id = ?", (gitea_id,)).fetchone() + if existing is None: + role = "owner" if config.owner_gitea_login and login == config.owner_gitea_login else "contributor" + cur = c.execute( + """ + INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role) + VALUES (?, ?, ?, ?, ?, ?) + """, + (gitea_id, login, email, display, avatar, role), + ) + user_id = cur.lastrowid + else: + user_id = existing["id"] + role = existing["role"] + c.execute( + """ + UPDATE users + SET gitea_login = ?, email = ?, display_name = ?, avatar_url = ?, last_seen_at = datetime('now') + WHERE id = ? + """, + (login, email, display, avatar, user_id), + ) + + return SessionUser( + user_id=user_id, + gitea_id=gitea_id, + gitea_login=login, + display_name=display, + email=email, + avatar_url=avatar, + role=role, + ) + + +# ----- Session helpers ----- + +SESSION_USER_KEY = "user" +SESSION_STATE_KEY = "oauth_state" + + +def store_session(request: Request, user: SessionUser) -> None: + request.session[SESSION_USER_KEY] = { + "user_id": user.user_id, + "gitea_id": user.gitea_id, + "gitea_login": user.gitea_login, + "display_name": user.display_name, + "email": user.email, + "avatar_url": user.avatar_url, + "role": user.role, + } + + +def current_user(request: Request) -> SessionUser | None: + raw = request.session.get(SESSION_USER_KEY) + if not raw: + return None + # Re-read the role from the database every request so role changes + # take effect on the next API call without forcing a logout. + row = db.conn().execute( + "SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role FROM users WHERE id = ?", + (raw["user_id"],), + ).fetchone() + if row is None: + return None + return SessionUser( + user_id=row["id"], + gitea_id=row["gitea_id"], + gitea_login=row["gitea_login"], + display_name=row["display_name"], + email=row["email"] or "", + avatar_url=row["avatar_url"] or "", + role=row["role"], + ) + + +def require_user(request: Request) -> SessionUser: + user = current_user(request) + if user is None: + raise HTTPException(status_code=401, detail="Not authenticated") + return user + + +def require_contributor(request: Request) -> SessionUser: + """§6.1: authenticated, not write-muted.""" + user = require_user(request) + row = db.conn().execute("SELECT muted FROM users WHERE id = ?", (user.user_id,)).fetchone() + if row and row["muted"]: + raise HTTPException(status_code=403, detail="Your account is muted") + return user + + +def require_admin(request: Request) -> SessionUser: + """§6.1: owner or admin.""" + user = require_user(request) + if user.role not in ("owner", "admin"): + raise HTTPException(status_code=403, detail="Admin or owner role required") + return user + + +def new_state() -> str: + return secrets.token_urlsafe(16) diff --git a/backend/app/bot.py b/backend/app/bot.py new file mode 100644 index 0000000..f487a16 --- /dev/null +++ b/backend/app/bot.py @@ -0,0 +1,222 @@ +"""The bot wrapper. + +Per §1: the bot service account is the only Git writer in the system. +Per §6.5: every commit, branch creation, and PR merge carries an +On-behalf-of: trailer naming the acting user. + +This module is the single chokepoint. Every write to Gitea — file +creation, branch creation, PR open, PR merge, PR close — flows through +a Bot method that takes an `actor` (the authenticated user whose gesture +produced the action) and an `action_kind` (one of the values recorded +in the `actions` table). The wrapper: + + - calls the Gitea HTTP client with the bot's credentials, + - appends the trailer to commit/PR/comment bodies, + - records a row in `actions` so the app's accountability surface and + the Git log carry the same record. + +If you find yourself wanting to import gitea.py directly to perform a +write, the spec is right and you are wrong: the wrapper is the +invariant. Read operations live in `gitea.py` and can be called from +anywhere. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass + +from . import db +from .gitea import Gitea + + +@dataclass(frozen=True) +class Actor: + """The user whose gesture is producing a write.""" + user_id: int + gitea_login: str + display_name: str + email: str + + +def _trailer(actor: Actor) -> str: + return f"On-behalf-of: {actor.display_name} <{actor.gitea_login}>" + + +def _stamp(message_subject: str, message_body: str, actor: Actor) -> tuple[str, str]: + """Compose subject + body with the On-behalf-of trailer appended. + + Subject and body are returned separately because Gitea's merge API + takes them on distinct fields; for file commits we hand back a + single string in the caller. + """ + body = message_body.rstrip() + trailer = _trailer(actor) + if body: + return message_subject, f"{body}\n\n{trailer}" + return message_subject, trailer + + +def _stamp_single(message: str, actor: Actor) -> str: + subject, _, rest = message.partition("\n") + subject, body = _stamp(subject, rest.lstrip(), actor) + return f"{subject}\n\n{body}".rstrip() + + +def _log( + actor: Actor, + action_kind: str, + *, + rfc_slug: str | None = None, + branch_name: str | None = None, + pr_number: int | None = None, + bot_commit_sha: str | None = None, + details: dict | None = None, +) -> None: + db.conn().execute( + """ + INSERT INTO actions + (actor_user_id, on_behalf_of, action_kind, rfc_slug, branch_name, pr_number, bot_commit_sha, details) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + actor.user_id, + actor.gitea_login, + action_kind, + rfc_slug, + branch_name, + pr_number, + bot_commit_sha, + json.dumps(details) if details else None, + ), + ) + + +class Bot: + def __init__(self, gitea: Gitea): + self._gitea = gitea + + # ----- Meta repo: idea PRs (§9.1 / §9.2) ----- + + async def open_idea_pr( + self, + actor: Actor, + *, + org: str, + meta_repo: str, + slug: str, + file_contents: str, + pr_title: str, + pr_description: str, + ) -> dict: + """Per §9.1: open a meta-repo PR adding one file under rfcs/. + + One file per PR keeps idea submissions atomic and conflict-free. + The PR title and the file-add commit subject share §9.2's fixed + pattern; callers compose `pr_title` as `Propose: <Title>`. + """ + branch = f"propose/{slug}" + await self._gitea.create_branch(org, meta_repo, branch, from_branch="main") + commit_subject = pr_title # §9.2: shared pattern + commit_message = _stamp_single(commit_subject, actor) + created = await self._gitea.create_file( + org, + meta_repo, + f"rfcs/{slug}.md", + content=file_contents, + message=commit_message, + branch=branch, + author_name=actor.display_name, + author_email=actor.email or f"{actor.gitea_login}@users.noreply", + ) + commit_sha = created.get("commit", {}).get("sha") + pr_body_subject, pr_body = _stamp("", pr_description, actor) + del pr_body_subject # only the body matters here + pr = await self._gitea.create_pull( + org, + meta_repo, + title=pr_title, + body=pr_body, + head=branch, + base="main", + ) + _log( + actor, + "propose_rfc", + rfc_slug=slug, + branch_name=branch, + pr_number=pr["number"], + bot_commit_sha=commit_sha, + details={"pr_title": pr_title}, + ) + return pr + + async def merge_idea_pr( + self, + actor: Actor, + *, + org: str, + meta_repo: str, + pr_number: int, + slug: str, + ) -> None: + """Per §9.3: owner/admin merges an idea PR, creating the super-draft.""" + subject = f"Merge proposal: {slug}" + body = _trailer(actor) + await self._gitea.merge_pull( + org, + meta_repo, + pr_number, + merge_message_title=subject, + merge_message_body=body, + ) + _log( + actor, + "merge_proposal", + rfc_slug=slug, + pr_number=pr_number, + ) + + async def decline_idea_pr( + self, + actor: Actor, + *, + org: str, + meta_repo: str, + pr_number: int, + slug: str, + comment: str, + ) -> None: + """Per §9.3: owner/admin declines an idea PR with a required comment. + + The comment is posted to the PR (the durable Git artifact) and a + mirroring system-author thread_messages row is written by the + caller so the chat record carries the act inline. + """ + commented = comment.strip() or "(no comment provided)" + body = f"{commented}\n\n{_trailer(actor)}" + await self._gitea.create_issue_comment(org, meta_repo, pr_number, body) + await self._gitea.close_pull(org, meta_repo, pr_number) + _log( + actor, + "decline_proposal", + rfc_slug=slug, + pr_number=pr_number, + details={"comment": commented}, + ) + + async def withdraw_idea_pr( + self, + actor: Actor, + *, + org: str, + meta_repo: str, + pr_number: int, + slug: str, + ) -> None: + await self._gitea.close_pull(org, meta_repo, pr_number) + _log( + actor, + "withdraw_proposal", + rfc_slug=slug, + pr_number=pr_number, + ) diff --git a/backend/app/cache.py b/backend/app/cache.py new file mode 100644 index 0000000..ad675a9 --- /dev/null +++ b/backend/app/cache.py @@ -0,0 +1,312 @@ +"""The §4 metadata cache and its two writers. + +Per §4: Gitea is truth. The cache mirrors only what the left pane and +the read surfaces need, and it is rebuildable from Gitea at any time. +Per §4.1: two writers — the webhook handler and the periodic reconciler — +both read from Gitea and write to the cache. User actions never write +to the cache directly; they trigger Git operations through the bot +(`bot.py`), and the resulting webhook (or the next reconciler sweep) +is what updates the cache. + +This module provides: + - `refresh_meta_repo()` — reads rfcs/ on the meta repo and reconciles + cached_rfcs against what's there. Used by both the webhook handler + (on meta-repo merge events) and the reconciler. + - `refresh_meta_pulls()` — reads open meta-repo PRs and reconciles + cached_prs for pr_kind='idea' and friends. Backs the §7.3 + pending-ideas disclosure. + +Per §4.2's "single SQLite file colocated with the FastAPI process," the +cache writes happen on the same process that serves reads; lock +contention is bounded by the small mutation surface (a few hundred +rows at most for v1) and SQLite's WAL mode. +""" +from __future__ import annotations + +import asyncio +import json +import logging + +from . import db, entry as entry_mod +from .config import Config +from .gitea import Gitea, GiteaError + +log = logging.getLogger(__name__) + + +async def refresh_meta_repo(config: Config, gitea: Gitea) -> None: + """Re-read rfcs/ on the meta repo and reconcile cached_rfcs. + + Idempotent. Safe to call on every meta-repo webhook and on every + reconciler sweep. + """ + org, repo = config.gitea_org, config.meta_repo + try: + files = await gitea.list_dir(org, repo, "rfcs", ref="main") + except GiteaError as e: + log.warning("refresh_meta_repo: cannot list rfcs/: %s", e) + return + + seen_slugs: set[str] = set() + for f in files: + if f.get("type") != "file" or not f.get("name", "").endswith(".md"): + continue + result = await gitea.read_file(org, repo, f["path"], ref="main") + if not result: + continue + text, sha = result + try: + entry = entry_mod.parse(text) + except Exception as parse_err: + log.warning("refresh_meta_repo: skipping %s: %s", f["path"], parse_err) + continue + if not entry.slug: + log.warning("refresh_meta_repo: skipping %s: missing slug", f["path"]) + continue + seen_slugs.add(entry.slug) + _upsert_cached_rfc(entry, body_sha=sha) + + # Mark entries removed from the meta repo as withdrawn-without-trace. + # In practice the spec keeps withdrawn entries in rfcs/ as historical + # record (§3), so this branch fires only for entries deleted out of + # band. We leave the row but flag it for reconciler attention. + existing = {row["slug"] for row in db.conn().execute("SELECT slug FROM cached_rfcs")} + for missing in existing - seen_slugs: + log.info("refresh_meta_repo: %s no longer in rfcs/ — leaving cache row in place", missing) + + +def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None: + db.conn().execute( + """ + INSERT INTO cached_rfcs + (slug, title, state, rfc_id, repo, proposed_by, proposed_at, + graduated_at, graduated_by, owners_json, arbiters_json, tags_json, + body, body_sha, last_entry_commit_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + ON CONFLICT(slug) DO UPDATE SET + title = excluded.title, + state = excluded.state, + rfc_id = excluded.rfc_id, + repo = excluded.repo, + proposed_by = excluded.proposed_by, + proposed_at = excluded.proposed_at, + graduated_at = excluded.graduated_at, + graduated_by = excluded.graduated_by, + owners_json = excluded.owners_json, + arbiters_json = excluded.arbiters_json, + tags_json = excluded.tags_json, + body = excluded.body, + body_sha = excluded.body_sha, + last_entry_commit_at = datetime('now'), + updated_at = datetime('now') + """, + ( + entry.slug, + entry.title, + entry.state, + entry.id, + entry.repo, + entry.proposed_by, + entry.proposed_at, + entry.graduated_at, + entry.graduated_by, + json.dumps(entry.owners), + json.dumps(entry.arbiters), + json.dumps(entry.tags), + entry.body, + body_sha, + ), + ) + + +async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None: + """Reconcile open meta-repo PRs into cached_prs. + + For Slice 1 we care about pr_kind='idea' (proposing a new entry). + Other meta-repo PR kinds (body edits, metadata edits, claims) will + be wired in their respective slices. + + `opened_by` is the **underlying actor**, not the bot login Gitea + reports — per §15.9's framing for notifications and per §6.5's + On-behalf-of accountability shape. We recover the actor by joining + against the `actions` audit log; if no row matches (cache rebuilt + from scratch on a deployment that pre-dates the actions log, or a + pull we did not author), we fall back to parsing the + `On-behalf-of:` trailer from the PR body, then to the raw Gitea + login as last resort. + """ + org, repo = config.gitea_org, config.meta_repo + repo_full = f"{org}/{repo}" + try: + open_pulls = await gitea.list_pulls(org, repo, state="open") + closed_pulls = await gitea.list_pulls(org, repo, state="closed") + except GiteaError as e: + log.warning("refresh_meta_pulls: %s", e) + return + + bot_login = config.gitea_bot_user + + for pull in open_pulls + closed_pulls: + head_branch = pull.get("head", {}).get("ref", "") + slug = _slug_from_head_branch(head_branch) + if slug is None: + continue + pr_kind = _kind_from_branch(head_branch) + state = _state_from_pull(pull) + gitea_opener = (pull.get("user") or {}).get("login") or "" + opened_by = _resolve_actor( + gitea_opener, + bot_login, + slug, + pull["number"], + pull.get("body") or "", + ) + db.conn().execute( + """ + INSERT INTO cached_prs + (rfc_slug, pr_kind, repo, pr_number, title, description, state, + opened_by, opened_at, merged_at, closed_at, + head_branch, base_branch, head_sha) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(repo, pr_number) DO UPDATE SET + title = excluded.title, + description = excluded.description, + state = excluded.state, + opened_by = excluded.opened_by, + merged_at = excluded.merged_at, + closed_at = excluded.closed_at, + head_sha = excluded.head_sha + """, + ( + slug, + pr_kind, + repo_full, + pull["number"], + pull.get("title") or "", + pull.get("body") or "", + state, + opened_by, + pull.get("created_at"), + pull.get("merged_at"), + pull.get("closed_at"), + head_branch, + (pull.get("base") or {}).get("ref") or "main", + (pull.get("head") or {}).get("sha"), + ), + ) + + +_TRAILER_RE = None + + +def _resolve_actor(gitea_opener: str, bot_login: str, slug: str, pr_number: int, body: str) -> str: + """Best effort: collapse the bot's authorship to the underlying actor.""" + if gitea_opener and gitea_opener != bot_login: + return gitea_opener + # Prefer the audit log. + row = db.conn().execute( + """ + SELECT on_behalf_of FROM actions + WHERE action_kind IN ('propose_rfc', 'open_body_edit_pr', 'open_claim_pr', 'open_metadata_pr') + AND rfc_slug = ? AND pr_number = ? + ORDER BY id LIMIT 1 + """, + (slug, pr_number), + ).fetchone() + if row and row["on_behalf_of"]: + return row["on_behalf_of"] + # Fall back to parsing the On-behalf-of trailer. + import re as _re + global _TRAILER_RE + if _TRAILER_RE is None: + _TRAILER_RE = _re.compile(r"On-behalf-of:\s+.*?<([^>]+)>", _re.MULTILINE) + m = _TRAILER_RE.search(body) + if m: + return m.group(1) + return gitea_opener or bot_login + + +def _slug_from_head_branch(head_branch: str) -> str | None: + if head_branch.startswith("propose/"): + return head_branch[len("propose/") :] + if head_branch.startswith("edit/"): + parts = head_branch.split("/", 2) + if len(parts) >= 2: + return parts[1] + if head_branch.startswith("claim/"): + return head_branch[len("claim/") :] + if head_branch.startswith("metadata/"): + return head_branch[len("metadata/") :] + return None + + +def _kind_from_branch(head_branch: str) -> str: + if head_branch.startswith("propose/"): + return "idea" + if head_branch.startswith("edit/"): + return "meta_body_edit" + if head_branch.startswith("claim/"): + return "meta_claim" + if head_branch.startswith("metadata/"): + return "meta_metadata" + return "idea" # fallback + + +def _state_from_pull(pull: dict) -> str: + if pull.get("merged"): + return "merged" + if pull.get("state") == "closed": + return "closed" + return "open" + + +# ----- Reconciler ----- + +class Reconciler: + """Per §4.1: periodic safety-net sweep. + + Runs in the background, every five minutes by default. Catches up + on any webhook the bot missed (downtime, network failure, Gitea + flake). If the cache is corrupted, the reconciler rebuilds from + scratch — that's the contract. + """ + + def __init__(self, config: Config, gitea: Gitea, interval_seconds: int = 300): + self._config = config + self._gitea = gitea + self._interval = interval_seconds + self._task: asyncio.Task | None = None + self._stop = asyncio.Event() + + async def _loop(self) -> None: + # One sweep at startup, then on the interval. The startup sweep + # is what brings a fresh cache to life on first boot. + await self.sweep() + while not self._stop.is_set(): + try: + await asyncio.wait_for(self._stop.wait(), timeout=self._interval) + except asyncio.TimeoutError: + pass + if self._stop.is_set(): + break + await self.sweep() + + async def sweep(self) -> None: + log.info("reconciler: starting sweep") + try: + await refresh_meta_repo(self._config, self._gitea) + await refresh_meta_pulls(self._config, self._gitea) + except Exception: + log.exception("reconciler: sweep failed") + else: + log.info("reconciler: sweep complete") + + def start(self) -> None: + if self._task is None: + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + self._stop.set() + if self._task is not None: + await self._task + self._task = None diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..5417a53 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,80 @@ +"""Environment-derived configuration. + +Loaded once at process start. Every module that needs a value pulls it from +here rather than re-reading os.environ, so there is one obvious place to +look when a setting is missing. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + + +def _required(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise RuntimeError(f"Required environment variable {name} is not set") + return value + + +def _optional(name: str, default: str = "") -> str: + return os.environ.get(name, default).strip() + + +@dataclass +class Config: + gitea_url: str + gitea_bot_user: str + gitea_bot_token: str + gitea_org: str + meta_repo: str + oauth_client_id: str + oauth_client_secret: str + app_url: str + secret_key: str + database_path: Path + owner_gitea_login: str + webhook_secret: str + enabled_models: list[str] = field(default_factory=list) + anthropic_api_key: str = "" + google_api_key: str = "" + openai_api_key: str = "" + + @property + def redirect_uri(self) -> str: + return f"{self.app_url}/auth/callback" + + @property + def meta_repo_full(self) -> str: + return f"{self.gitea_org}/{self.meta_repo}" + + +def load_config() -> Config: + database_path = Path(_optional("DATABASE_PATH", "data/rfc-app.db")).resolve() + database_path.parent.mkdir(parents=True, exist_ok=True) + + enabled = [m.strip() for m in _optional("ENABLED_MODELS", "claude").split(",") if m.strip()] + + return Config( + gitea_url=_required("GITEA_URL").rstrip("/"), + gitea_bot_user=_required("GITEA_BOT_USER"), + gitea_bot_token=_required("GITEA_BOT_TOKEN"), + gitea_org=_required("GITEA_ORG"), + meta_repo=_optional("META_REPO", "meta"), + oauth_client_id=_required("OAUTH_CLIENT_ID"), + oauth_client_secret=_required("OAUTH_CLIENT_SECRET"), + app_url=_optional("APP_URL", "http://localhost:8000").rstrip("/"), + secret_key=_required("SECRET_KEY"), + database_path=database_path, + owner_gitea_login=_optional("OWNER_GITEA_LOGIN"), + webhook_secret=_optional("GITEA_WEBHOOK_SECRET"), + enabled_models=enabled, + anthropic_api_key=_optional("ANTHROPIC_API_KEY"), + google_api_key=_optional("GOOGLE_API_KEY"), + openai_api_key=_optional("OPENAI_API_KEY"), + ) diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..8b81acb --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,84 @@ +"""SQLite connection and migration runner. + +The schema lives in backend/migrations/ as numbered .sql files; this module +runs them in order against the configured database file and exposes a +connection factory that the rest of the app uses. WAL is enabled because +the SSE handlers and the reconciler can read while a webhook writes; FK +enforcement is on because §5's cascade rules depend on it. + +Per §4.2, single SQLite file colocated with the FastAPI process. If we +outgrow this, the spec calls for a planned migration to Postgres on a +separate host — not for a clever sharding scheme bolted on here. +""" +from __future__ import annotations + +import sqlite3 +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + +from .config import Config + +MIGRATIONS_DIR = Path(__file__).resolve().parent.parent / "migrations" + + +def connect(path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(path, isolation_level=None, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA synchronous = NORMAL") + return conn + + +def run_migrations(config: Config) -> None: + conn = connect(config.database_path) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """ + ) + applied = {row["version"] for row in conn.execute("SELECT version FROM schema_migrations")} + for path in sorted(MIGRATIONS_DIR.glob("*.sql")): + version = path.stem + if version in applied: + continue + sql = path.read_text() + conn.executescript("BEGIN; " + sql + "; COMMIT;") + conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,)) + finally: + conn.close() + + +_CONN: sqlite3.Connection | None = None + + +def init(config: Config) -> None: + """Open the long-lived connection. Call once at startup, after migrations.""" + global _CONN + if _CONN is not None: + return + _CONN = connect(config.database_path) + + +def conn() -> sqlite3.Connection: + if _CONN is None: + raise RuntimeError("db.init() has not been called") + return _CONN + + +@contextmanager +def tx() -> Iterator[sqlite3.Connection]: + """Wrap a block in a transaction. Rolls back on exception.""" + c = conn() + c.execute("BEGIN") + try: + yield c + c.execute("COMMIT") + except Exception: + c.execute("ROLLBACK") + raise diff --git a/backend/app/entry.py b/backend/app/entry.py new file mode 100644 index 0000000..568c0cf --- /dev/null +++ b/backend/app/entry.py @@ -0,0 +1,102 @@ +"""Meta-repo entry file shape per §2.1. + +One markdown file per RFC under rfcs/<slug>.md, frontmatter on top, body +below. The frontmatter carries the canonical RFC state — id, repo, +owners, arbiters, graduation timestamps — and the body holds the pitch +(for super-drafts) or is empty (for graduated entries per §13.3 step 3). + +This module contains the parser, the serializer, and a small validator +for the frontmatter shape. The parser is intentionally lenient about +unknown keys — future fields land in frontmatter without breaking older +readers. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from datetime import date +from typing import Any + +import yaml + +FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.DOTALL) + +SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") + + +@dataclass +class Entry: + slug: str + title: str + state: str = "super-draft" # super-draft | active | withdrawn + id: str | None = None # 'RFC-NNNN' or None + repo: str | None = None + proposed_by: str = "" + proposed_at: str = "" # ISO date + graduated_at: str | None = None + graduated_by: str | None = None + owners: list[str] = field(default_factory=list) + arbiters: list[str] = field(default_factory=list) + tags: list[str] = field(default_factory=list) + body: str = "" + + +def parse(text: str) -> Entry: + match = FRONTMATTER_RE.match(text) + if not match: + raise ValueError("Entry file missing frontmatter") + fm = yaml.safe_load(match.group(1)) or {} + body = match.group(2).lstrip("\n") + return Entry( + slug=str(fm.get("slug") or ""), + title=str(fm.get("title") or ""), + state=str(fm.get("state") or "super-draft"), + id=fm.get("id") or None, + repo=fm.get("repo") or None, + proposed_by=str(fm.get("proposed_by") or ""), + proposed_at=str(fm.get("proposed_at") or ""), + graduated_at=fm.get("graduated_at"), + graduated_by=fm.get("graduated_by"), + owners=list(fm.get("owners") or []), + arbiters=list(fm.get("arbiters") or []), + tags=list(fm.get("tags") or []), + body=body, + ) + + +def serialize(entry: Entry) -> str: + """Emit canonical entry file text — frontmatter then body.""" + fm: dict[str, Any] = { + "slug": entry.slug, + "title": entry.title, + "state": entry.state, + "id": entry.id, + "repo": entry.repo, + "proposed_by": entry.proposed_by, + "proposed_at": entry.proposed_at, + "graduated_at": entry.graduated_at, + "graduated_by": entry.graduated_by, + "owners": entry.owners, + "arbiters": entry.arbiters, + "tags": entry.tags, + } + yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip() + body = entry.body.lstrip("\n") + if body: + return f"---\n{yaml_text}\n---\n\n{body}\n" + return f"---\n{yaml_text}\n---\n" + + +def slugify(title: str) -> str: + """Deterministic kebab-case per §9.1.""" + s = title.lower().strip() + s = re.sub(r"[^a-z0-9]+", "-", s) + return s.strip("-") + + +def today() -> str: + return date.today().isoformat() + + +def is_valid_slug(slug: str) -> bool: + return bool(SLUG_RE.match(slug)) and len(slug) <= 80 diff --git a/backend/app/gitea.py b/backend/app/gitea.py new file mode 100644 index 0000000..598f6e4 --- /dev/null +++ b/backend/app/gitea.py @@ -0,0 +1,286 @@ +"""Thin HTTP client for the Gitea REST API. + +Read operations live here and are called from any module that needs them +(reconciler, super-draft body fetch, webhook handler). Write operations +also live here but are not called directly from outside this module — +they are wrapped by `bot.py` per §1, so that every commit, branch, and +PR carries the §6.5 On-behalf-of trailer and a row in the actions log. + +This split keeps the chokepoint legible: anything that wants to read +imports from here; anything that wants to write imports from `bot.py` +and never reaches around it. +""" +from __future__ import annotations + +import base64 +from typing import Any + +import httpx + +from .config import Config + + +class GiteaError(Exception): + def __init__(self, status: int, detail: str): + super().__init__(f"Gitea {status}: {detail}") + self.status = status + self.detail = detail + + +class Gitea: + def __init__(self, config: Config): + self._config = config + self._client = httpx.AsyncClient( + base_url=f"{config.gitea_url}/api/v1", + headers={ + "Authorization": f"token {config.gitea_bot_token}", + "Accept": "application/json", + }, + timeout=30.0, + ) + + async def close(self) -> None: + await self._client.aclose() + + async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: + resp = await self._client.request(method, path, **kwargs) + if resp.status_code >= 400: + try: + detail = resp.json().get("message", resp.text) + except Exception: + detail = resp.text + raise GiteaError(resp.status_code, detail) + return resp + + # ----- Repo lifecycle ----- + + async def get_repo(self, owner: str, repo: str) -> dict | None: + try: + resp = await self._request("GET", f"/repos/{owner}/{repo}") + return resp.json() + except GiteaError as e: + if e.status == 404: + return None + raise + + async def create_org_repo(self, org: str, name: str, *, description: str = "", private: bool = False) -> dict: + resp = await self._request( + "POST", + f"/orgs/{org}/repos", + json={ + "name": name, + "description": description, + "private": private, + "auto_init": False, + }, + ) + return resp.json() + + async def delete_repo(self, owner: str, repo: str) -> None: + await self._request("DELETE", f"/repos/{owner}/{repo}") + + # ----- Branches ----- + + async def get_branch(self, owner: str, repo: str, branch: str) -> dict | None: + try: + resp = await self._request("GET", f"/repos/{owner}/{repo}/branches/{branch}") + return resp.json() + except GiteaError as e: + if e.status == 404: + return None + raise + + async def list_branches(self, owner: str, repo: str) -> list[dict]: + resp = await self._request("GET", f"/repos/{owner}/{repo}/branches", params={"limit": 50}) + return resp.json() + + async def create_branch(self, owner: str, repo: str, new_branch: str, from_branch: str = "main") -> dict: + resp = await self._request( + "POST", + f"/repos/{owner}/{repo}/branches", + json={"new_branch_name": new_branch, "old_branch_name": from_branch}, + ) + return resp.json() + + async def delete_branch(self, owner: str, repo: str, branch: str) -> None: + await self._request("DELETE", f"/repos/{owner}/{repo}/branches/{branch}") + + # ----- File contents ----- + + async def get_contents(self, owner: str, repo: str, path: str, ref: str = "main") -> dict | None: + try: + resp = await self._request( + "GET", + f"/repos/{owner}/{repo}/contents/{path}", + params={"ref": ref}, + ) + return resp.json() + except GiteaError as e: + if e.status == 404: + return None + raise + + async def list_dir(self, owner: str, repo: str, path: str = "", ref: str = "main") -> list[dict]: + try: + resp = await self._request( + "GET", + f"/repos/{owner}/{repo}/contents/{path}", + params={"ref": ref}, + ) + except GiteaError as e: + if e.status == 404: + return [] + raise + data = resp.json() + return data if isinstance(data, list) else [data] + + async def read_file(self, owner: str, repo: str, path: str, ref: str = "main") -> tuple[str, str] | None: + """Return (content, sha) or None if the file is missing.""" + item = await self.get_contents(owner, repo, path, ref) + if not item or item.get("type") != "file": + return None + return base64.b64decode(item["content"]).decode("utf-8"), item["sha"] + + async def create_file( + self, + owner: str, + repo: str, + path: str, + *, + content: str, + message: str, + branch: str, + author_name: str | None = None, + author_email: str | None = None, + ) -> dict: + body: dict[str, Any] = { + "message": message, + "content": base64.b64encode(content.encode("utf-8")).decode("ascii"), + "branch": branch, + } + if author_name and author_email: + body["author"] = {"name": author_name, "email": author_email} + body["committer"] = {"name": author_name, "email": author_email} + resp = await self._request("POST", f"/repos/{owner}/{repo}/contents/{path}", json=body) + return resp.json() + + async def update_file( + self, + owner: str, + repo: str, + path: str, + *, + content: str, + sha: str, + message: str, + branch: str, + author_name: str | None = None, + author_email: str | None = None, + ) -> dict: + body: dict[str, Any] = { + "message": message, + "content": base64.b64encode(content.encode("utf-8")).decode("ascii"), + "sha": sha, + "branch": branch, + } + if author_name and author_email: + body["author"] = {"name": author_name, "email": author_email} + body["committer"] = {"name": author_name, "email": author_email} + resp = await self._request("PUT", f"/repos/{owner}/{repo}/contents/{path}", json=body) + return resp.json() + + # ----- Pull requests ----- + + async def list_pulls(self, owner: str, repo: str, state: str = "open") -> list[dict]: + resp = await self._request( + "GET", + f"/repos/{owner}/{repo}/pulls", + params={"state": state, "limit": 50}, + ) + return resp.json() + + async def get_pull(self, owner: str, repo: str, number: int) -> dict | None: + try: + resp = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}") + return resp.json() + except GiteaError as e: + if e.status == 404: + return None + raise + + async def create_pull( + self, + owner: str, + repo: str, + *, + title: str, + body: str, + head: str, + base: str = "main", + ) -> dict: + resp = await self._request( + "POST", + f"/repos/{owner}/{repo}/pulls", + json={"title": title, "body": body, "head": head, "base": base}, + ) + return resp.json() + + async def merge_pull( + self, + owner: str, + repo: str, + number: int, + *, + merge_message_title: str, + merge_message_body: str, + style: str = "merge", + ) -> None: + # Per §10.5: no-fast-forward merge commit. We pass Do='merge' so + # Gitea produces a merge commit rather than a fast-forward. + await self._request( + "POST", + f"/repos/{owner}/{repo}/pulls/{number}/merge", + json={ + "Do": style, + "MergeTitleField": merge_message_title, + "MergeMessageField": merge_message_body, + }, + ) + + async def close_pull(self, owner: str, repo: str, number: int) -> None: + await self._request( + "PATCH", + f"/repos/{owner}/{repo}/issues/{number}", + json={"state": "closed"}, + ) + + async def create_issue_comment(self, owner: str, repo: str, number: int, body: str) -> dict: + resp = await self._request( + "POST", + f"/repos/{owner}/{repo}/issues/{number}/comments", + json={"body": body}, + ) + return resp.json() + + # ----- Webhooks ----- + + async def ensure_webhook(self, owner: str, repo: str, *, url: str, secret: str, events: list[str]) -> dict: + existing = (await self._request("GET", f"/repos/{owner}/{repo}/hooks")).json() + for hook in existing: + if hook.get("config", {}).get("url") == url: + return hook + resp = await self._request( + "POST", + f"/repos/{owner}/{repo}/hooks", + json={ + "type": "gitea", + "active": True, + "events": events, + "config": { + "url": url, + "content_type": "json", + "secret": secret, + }, + }, + ) + return resp.json() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..6b9ea13 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,102 @@ +"""FastAPI entrypoint. + +Wires the §17 routers, the OAuth callbacks, the webhook receiver, and +the background reconciler. Per §4.2, single process, colocated SQLite — +no need for a separate worker. +""" +from __future__ import annotations + +import logging +import secrets +from contextlib import asynccontextmanager + +from fastapi import APIRouter, FastAPI, HTTPException, Request +from fastapi.responses import RedirectResponse +from starlette.middleware.sessions import SessionMiddleware + +from . import api as api_routes, auth, cache, db, webhooks +from .bot import Bot +from .config import load_config +from .gitea import Gitea + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") +log = logging.getLogger("rfc_app") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + config = load_config() + db.run_migrations(config) + db.init(config) + gitea = Gitea(config) + bot = Bot(gitea) + reconciler = cache.Reconciler(config, gitea) + + app.state.config = config + app.state.gitea = gitea + app.state.bot = bot + app.state.reconciler = reconciler + + app.include_router(_oauth_router(config)) + app.include_router(api_routes.make_router(config, gitea, bot)) + app.include_router(webhooks.make_router(config, gitea)) + + reconciler.start() + log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo) + try: + yield + finally: + await reconciler.stop() + await gitea.close() + + +def create_app() -> FastAPI: + # The secret key is required at app construction (SessionMiddleware + # is added before lifespan runs), so we read just that one value + # eagerly via load_config(). Everything else waits for lifespan. + config = load_config() + app = FastAPI(lifespan=lifespan) + app.add_middleware( + SessionMiddleware, + secret_key=config.secret_key, + session_cookie="rfc_session", + max_age=60 * 60 * 24 * 30, + https_only=False, + ) + return app + + +app = create_app() + + +def _oauth_router(config) -> APIRouter: + router = APIRouter() + + @router.get("/auth/login") + async def login(request: Request): + state = auth.new_state() + request.session[auth.SESSION_STATE_KEY] = state + return RedirectResponse(auth.authorization_url(config, state)) + + @router.get("/auth/callback") + async def callback(request: Request, code: str = "", state: str = ""): + if not code: + raise HTTPException(400, "Missing code") + stored_state = request.session.get(auth.SESSION_STATE_KEY) + if not stored_state or not secrets.compare_digest(stored_state, state): + raise HTTPException(400, "Invalid state") + token_data = await auth.exchange_code(config, code) + access_token = token_data.get("access_token") + if not access_token: + raise HTTPException(400, "Token exchange failed") + profile = await auth.fetch_user_profile(config, access_token) + user = auth.provision_user(config, profile) + auth.store_session(request, user) + return RedirectResponse("/") + + @router.get("/auth/logout") + async def logout(request: Request): + request.session.clear() + return RedirectResponse("/") + + return router diff --git a/backend/app/webhooks.py b/backend/app/webhooks.py new file mode 100644 index 0000000..041ecb9 --- /dev/null +++ b/backend/app/webhooks.py @@ -0,0 +1,71 @@ +"""Gitea webhook receiver per §4.1. + +Both the webhook receiver and the reconciler are §4.1 cache writers. +On a meaningful event — meta-repo push or PR change — we re-read just +what changed from Gitea and update the cache. The signature is verified +against the configured shared secret so spurious POSTs cannot poison +the cache. +""" +from __future__ import annotations + +import hashlib +import hmac +import logging + +from fastapi import APIRouter, Header, HTTPException, Request + +from . import cache +from .config import Config +from .gitea import Gitea + +log = logging.getLogger(__name__) + +EVENTS_OF_INTEREST = { + "push", # meta-repo or RFC-repo commits + "pull_request", # opened / closed / merged + "create", # branch or repo created + "delete", # branch deleted + "repository", # repo created or deleted +} + + +def make_router(config: Config, gitea: Gitea) -> APIRouter: + router = APIRouter() + + @router.post("/api/webhooks/gitea") + async def receive( + request: Request, + x_gitea_event: str = Header(default=""), + x_gitea_signature: str = Header(default=""), + ): + body = await request.body() + if config.webhook_secret: + if not _verify_signature(body, x_gitea_signature, config.webhook_secret): + raise HTTPException(status_code=401, detail="Invalid signature") + + event = x_gitea_event.lower() + if event not in EVENTS_OF_INTEREST: + return {"ok": True, "ignored": event} + + # Slice 1 only acts on meta-repo events; per-RFC-repo events + # land in their respective slices. The handler is generous in + # what it accepts — any meta-repo change is a cue to refresh + # the whole meta-repo cache, since the cache is small and the + # refresh is idempotent. + try: + await cache.refresh_meta_repo(config, gitea) + await cache.refresh_meta_pulls(config, gitea) + except Exception: + log.exception("webhook refresh failed") + raise HTTPException(status_code=500, detail="Refresh failed") + + return {"ok": True} + + return router + + +def _verify_signature(body: bytes, header: str, secret: str) -> bool: + if not header: + return False + expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, header) diff --git a/backend/migrations/001_users_and_audit.sql b/backend/migrations/001_users_and_audit.sql new file mode 100644 index 0000000..8fcbc94 --- /dev/null +++ b/backend/migrations/001_users_and_audit.sql @@ -0,0 +1,65 @@ +-- §5 / §6: users, permission events, and audit log. +-- +-- The users table is the app-owned canonical account record. Per §6.1, +-- role is one of owner / admin / contributor; anonymous is the absence +-- of a row (or the absence of a session). The §6.2 app-wide write-mute +-- lives here as `muted`, structurally distinct from the §15.6 per-RFC +-- mute (on watches) and the §15.8 per-user mute (notification_user_mutes). +-- +-- Per §15, the per-user notification preferences are inlined for +-- proximity. The watched-RFC-churn category has no column per §15.4 — +-- it is permanently off and surfaces in settings as a disabled toggle. + +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + gitea_id INTEGER UNIQUE NOT NULL, + gitea_login TEXT UNIQUE NOT NULL, + email TEXT, + display_name TEXT NOT NULL, + avatar_url TEXT, + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')), + muted INTEGER NOT NULL DEFAULT 0, -- §6.2 app-wide write-mute + email_personal_direct INTEGER NOT NULL DEFAULT 1, -- §15.4 default on + email_watched_structural INTEGER NOT NULL DEFAULT 0, -- §15.4 default off + email_admin_actionable INTEGER NOT NULL DEFAULT 1, -- §15.4 default on for admins/owners; ignored for contributors + digest_cadence TEXT NOT NULL DEFAULT 'weekly' CHECK (digest_cadence IN ('off', 'weekly', 'daily')), -- §15.5 + notification_quiet_hours_start TEXT, -- §15.8 ISO-8601 local time HH:MM + notification_quiet_hours_end TEXT, + notification_quiet_hours_timezone TEXT, -- IANA tz name + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_users_role ON users (role); + +-- §6.5: permission-change audit. Append-only. Every mute, role grant, +-- or capability override produces a row here. +CREATE TABLE permission_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + subject_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_kind TEXT NOT NULL, -- e.g. role_changed, muted, restored + details TEXT, -- JSON blob with before/after, reason, etc. + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_permission_events_subject ON permission_events (subject_user_id, created_at); + +-- §5: append-only action log. Every state transition, every graduation, +-- every grant change. Includes the on-behalf-of trailer per §6.5 so the +-- audit log and the Git log carry the same accountability. +CREATE TABLE actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + on_behalf_of TEXT NOT NULL, -- the gitea_login the bot acted on behalf of + action_kind TEXT NOT NULL, -- propose_rfc, merge_proposal, graduate, etc. + rfc_slug TEXT, + branch_name TEXT, + pr_number INTEGER, + bot_commit_sha TEXT, + details TEXT, -- JSON blob with kind-specific extras + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_actions_rfc ON actions (rfc_slug, created_at); +CREATE INDEX idx_actions_actor ON actions (actor_user_id, created_at); diff --git a/backend/migrations/002_cache.sql b/backend/migrations/002_cache.sql new file mode 100644 index 0000000..c43242f --- /dev/null +++ b/backend/migrations/002_cache.sql @@ -0,0 +1,82 @@ +-- §4: the metadata cache. Reconstructible from Gitea at any time by the +-- §4.1 reconciler; never written from user actions, only from webhook +-- handlers and reconciler sweeps. Body content is cached for main-branch +-- reads (§4 #3); branch bodies are not. +-- +-- These tables are not in §5's "canonical app tables" list because they +-- are cache, not truth — but they are required for the left-pane render +-- path and for serving super-draft and main-branch bodies without a +-- Gitea round-trip on every navigation. + +-- One row per meta-repo rfcs/<slug>.md entry. Mirrors §2.1 frontmatter +-- plus the cached body for super-draft preview (graduated entries have +-- frontmatter-only bodies per §13.3 step 3, but the field is reused). +CREATE TABLE cached_rfcs ( + slug TEXT PRIMARY KEY, + title TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn')), + rfc_id TEXT, -- 'RFC-NNNN' or NULL until graduated + repo TEXT, -- 'org/repo' or NULL until graduated + proposed_by TEXT, -- gitea login or email + proposed_at TEXT, + graduated_at TEXT, + graduated_by TEXT, + owners_json TEXT NOT NULL DEFAULT '[]', + arbiters_json TEXT NOT NULL DEFAULT '[]', + tags_json TEXT NOT NULL DEFAULT '[]', + body TEXT, -- super-draft body or main RFC.md body + body_sha TEXT, -- the commit sha the body was fetched at + last_main_commit_at TEXT, -- §7.1's "Recently active" sort + last_entry_commit_at TEXT, -- last meta-repo commit touching this entry + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state); +CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs ( + COALESCE(last_main_commit_at, last_entry_commit_at) DESC +); + +-- One row per branch the bot knows about on either a per-RFC repo +-- (rfc_slug, state='active'') or on the meta repo as a super-draft edit +-- branch (rfc_slug, state='super-draft', branch_name like 'edit/<slug>/...'). +-- §11.5: closed branches stay; deleted branches keep their metadata row +-- per §12 ("branch removed from Gitea, row remains"). +CREATE TABLE cached_branches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfc_slug TEXT NOT NULL, + branch_name TEXT NOT NULL, + head_sha TEXT, + state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'deleted')), + pinned INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_commit_at TEXT, + closed_at TEXT, + UNIQUE (rfc_slug, branch_name) +); + +CREATE INDEX idx_cached_branches_rfc ON cached_branches (rfc_slug, state); + +-- One row per PR the bot knows about. Includes meta-repo idea PRs (rfc_slug +-- carries the proposed slug, see §5 super-draft scoping note) and meta-repo +-- body-edit PRs and per-RFC-repo PRs. The pr_kind disambiguates. +CREATE TABLE cached_prs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfc_slug TEXT NOT NULL, + pr_kind TEXT NOT NULL CHECK (pr_kind IN ('idea', 'meta_body_edit', 'meta_metadata', 'meta_claim', 'rfc_branch')), + repo TEXT NOT NULL, -- 'org/repo' the PR lives on + pr_number INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + state TEXT NOT NULL CHECK (state IN ('open', 'merged', 'closed', 'withdrawn')), + opened_by TEXT, -- gitea login (resolved from On-behalf-of trailer where present) + opened_at TEXT, + merged_at TEXT, + closed_at TEXT, + head_branch TEXT, + base_branch TEXT NOT NULL DEFAULT 'main', + head_sha TEXT, + UNIQUE (repo, pr_number) +); + +CREATE INDEX idx_cached_prs_rfc ON cached_prs (rfc_slug, state); +CREATE INDEX idx_cached_prs_kind ON cached_prs (pr_kind, state); diff --git a/backend/migrations/003_branches_grants_stars.sql b/backend/migrations/003_branches_grants_stars.sql new file mode 100644 index 0000000..281d587 --- /dev/null +++ b/backend/migrations/003_branches_grants_stars.sql @@ -0,0 +1,38 @@ +-- §5 / §6.4 / §11.1: per-branch visibility and contribute settings. +-- These rows are app data, not cache. They describe what the app permits +-- for a given branch; the bot enforces them before acting. Absence of a +-- row means defaults: read_public=1, contribute_mode='just-me'. + +CREATE TABLE branch_visibility ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfc_slug TEXT NOT NULL, + branch_name TEXT NOT NULL, + read_public INTEGER NOT NULL DEFAULT 1, + contribute_mode TEXT NOT NULL DEFAULT 'just-me' CHECK (contribute_mode IN ('just-me', 'specific', 'any-contributor')), + UNIQUE (rfc_slug, branch_name) +); + +CREATE TABLE branch_contribute_grants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfc_slug TEXT NOT NULL, + branch_name TEXT NOT NULL, + grantee_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + granted_by INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL, + granted_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (rfc_slug, branch_name, grantee_user_id) +); + +CREATE INDEX idx_grants_lookup ON branch_contribute_grants (rfc_slug, branch_name); +CREATE INDEX idx_grants_grantee ON branch_contribute_grants (grantee_user_id); + +-- §5 / §7.2: starred RFCs pin to the top of the current sort order. +CREATE TABLE stars ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rfc_slug TEXT NOT NULL, + starred_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (user_id, rfc_slug) +); + +CREATE INDEX idx_stars_user ON stars (user_id); +CREATE INDEX idx_stars_rfc ON stars (rfc_slug); diff --git a/backend/migrations/004_threads_and_changes.sql b/backend/migrations/004_threads_and_changes.sql new file mode 100644 index 0000000..a2765f1 --- /dev/null +++ b/backend/migrations/004_threads_and_changes.sql @@ -0,0 +1,73 @@ +-- §5: threads, thread_messages, changes — the conversation and revision +-- substrate. +-- +-- Per the §5 super-draft scoping note, rows with rfc_slug pointing at a +-- super-draft entry use branch_name to name a meta-repo branch rather +-- than a per-RFC-repo branch. The schema is identical either way; the +-- interpretation flows from the entry's state in cached_rfcs. +-- +-- Threads on a pending-idea PR (§9.3) carry the proposed slug as rfc_slug +-- pre-merge — slugs are reserved during the idea PR per §9.1's uniqueness +-- check — and surface under the super-draft on merge with no data movement. + +CREATE TABLE threads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfc_slug TEXT NOT NULL, + branch_name TEXT, -- NULL = scoped to the RFC's main view + anchor_kind TEXT NOT NULL CHECK (anchor_kind IN ('whole-doc', 'range', 'paragraph')), + anchor_payload TEXT, -- JSON: ProseMirror range or paragraph id + thread_kind TEXT NOT NULL CHECK (thread_kind IN ('chat', 'flag', 'review')), + label TEXT, -- short summary, or full flag content + state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'resolved', 'stale')), + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + resolved_at TEXT, + resolved_by INTEGER REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX idx_threads_scope ON threads (rfc_slug, branch_name, state); +CREATE INDEX idx_threads_kind ON threads (thread_kind, state); + +-- §5: chat content. Only chat-kind threads have rows here unless a flag +-- has been converted to a chat (§8.13). System-author messages (role='system', +-- author_user_id=NULL) carry the §10.6 manual-edit-flush markers, the §9.3 +-- decline-comment record, and similar system-narration entries. +CREATE TABLE thread_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')), + author_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + model_id TEXT, -- set when role='assistant' + text TEXT NOT NULL, + quote TEXT, -- optional selection the user attached + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_thread_messages_thread ON thread_messages (thread_id, created_at); +CREATE INDEX idx_thread_messages_author ON thread_messages (author_user_id, created_at); + +-- §5 / §8.6 / §8.9 / §8.11: structured proposed edits. AI-proposed (parsed +-- from <change> blocks per the §18 carryover) or manually authored. +-- stale_since is orthogonal to state: a stale AI proposal stays 'pending' +-- until the contributor acts on the staleness warning per §8.11. +CREATE TABLE changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfc_slug TEXT NOT NULL, + branch_name TEXT NOT NULL, + thread_id INTEGER REFERENCES threads(id) ON DELETE SET NULL, + source_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL, + kind TEXT NOT NULL CHECK (kind IN ('ai', 'manual')), + state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'accepted', 'declined')), + original TEXT NOT NULL, + proposed TEXT NOT NULL, + reason TEXT, + was_edited_before_accept INTEGER NOT NULL DEFAULT 0, + stale_since TEXT, + acted_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + acted_at TEXT, + commit_sha TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_changes_scope ON changes (rfc_slug, branch_name, state); +CREATE INDEX idx_changes_thread ON changes (thread_id); diff --git a/backend/migrations/005_seen_cursors_and_watches.sql b/backend/migrations/005_seen_cursors_and_watches.sql new file mode 100644 index 0000000..10bb0c8 --- /dev/null +++ b/backend/migrations/005_seen_cursors_and_watches.sql @@ -0,0 +1,45 @@ +-- §5 / §10.3 / §15.6 / §15.7: the freshness cursors and the watch model. +-- +-- Two cursor families per §15.7: per-event read state lives on notifications +-- (added in 006); per-scope freshness lives on pr_seen and branch_chat_seen. +-- They serve different jobs and are reconciled by the visit-advances-cursor +-- reconciler in §15.7. + +CREATE TABLE pr_seen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rfc_slug TEXT NOT NULL, + pr_number INTEGER NOT NULL, + last_seen_commit_sha TEXT, + last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL, + seen_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (user_id, rfc_slug, pr_number) +); + +CREATE TABLE branch_chat_seen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rfc_slug TEXT NOT NULL, + branch_name TEXT NOT NULL, + last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL, + seen_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (user_id, rfc_slug, branch_name) +); + +-- §15.6: the watch model. Three states; auto-rules upgrade but never +-- downgrade; explicit settings exempt from the 90-day decay. Per-RFC +-- mute lives here as state='muted'. +CREATE TABLE watches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rfc_slug TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('watching', 'following', 'muted')), + set_by TEXT NOT NULL CHECK (set_by IN ('auto', 'explicit')), + set_at TEXT NOT NULL DEFAULT (datetime('now')), + last_participation_at TEXT, -- 90-day decay key per §15.6 + UNIQUE (user_id, rfc_slug) +); + +CREATE INDEX idx_watches_user ON watches (user_id); +CREATE INDEX idx_watches_rfc ON watches (rfc_slug); +CREATE INDEX idx_watches_decay ON watches (state, last_participation_at); diff --git a/backend/migrations/006_notifications.sql b/backend/migrations/006_notifications.sql new file mode 100644 index 0000000..5e26653 --- /dev/null +++ b/backend/migrations/006_notifications.sql @@ -0,0 +1,57 @@ +-- §5 / §15: the notification substrate. Per §15.7, per-row read_at is what +-- the inbox needs because triage is per-event. Per §15.9, system-generated +-- events carry actor_user_id = NULL; the bot account does not appear here. +-- +-- Fan-out is at signal-generation time per §15.7: each recipient gets their +-- own row. This trades storage for query simplicity at the inbox surface, +-- and the §15.5 digest's exclusion rules need per-recipient timestamps +-- (email_sent_at, digest_included_at) anyway. + +CREATE TABLE notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_kind TEXT NOT NULL, -- §15.1 enum, extensible + rfc_slug TEXT, + branch_name TEXT, + pr_number INTEGER, + thread_id INTEGER REFERENCES threads(id) ON DELETE SET NULL, + change_id INTEGER REFERENCES changes(id) ON DELETE SET NULL, + actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- NULL = system per §15.9 + payload TEXT NOT NULL DEFAULT '{}', -- JSON: rendered row text + extras + created_at TEXT NOT NULL DEFAULT (datetime('now')), + read_at TEXT, -- §15.7 per-event triage + email_sent_at TEXT, -- §15.5 exclusion rule 1 + digest_included_at TEXT -- §15.5 exclusion rule 3 audit +); + +CREATE INDEX idx_notifications_inbox ON notifications (recipient_user_id, read_at, created_at); +CREATE INDEX idx_notifications_scope ON notifications (rfc_slug, branch_name, pr_number); +CREATE INDEX idx_notifications_digest ON notifications (recipient_user_id, digest_included_at); + +-- §15.5: per-recipient digest emissions. The period_start / period_end +-- pair makes the event-window dedup queryable at audit time. +CREATE TABLE notification_digests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + sent_at TEXT NOT NULL DEFAULT (datetime('now')), + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + signal_ids_included TEXT NOT NULL DEFAULT '[]' -- JSON array of notification ids +); + +CREATE INDEX idx_digests_recipient ON notification_digests (recipient_user_id, sent_at); + +-- §15.8: per-user notification mute. Notification-volume only; never +-- gates content visibility. The §6.2 clarification reads: an admin or +-- arbiter exercising authority on an RFC cannot mute participants on +-- that RFC. Enforcement of the role-exemption is in the API layer; the +-- schema just stores the mute. +CREATE TABLE notification_user_mutes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + muter_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + muted_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + muted_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (muter_user_id, muted_user_id) +); + +CREATE INDEX idx_user_mutes_muter ON notification_user_mutes (muter_user_id); diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..0b0b836 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,10 @@ +fastapi>=0.115 +uvicorn[standard]>=0.32 +httpx>=0.27 +python-dotenv>=1.0 +itsdangerous>=2.2 +pydantic>=2.9 +anthropic>=0.39 +google-generativeai>=0.8 +openai>=1.50 +PyYAML>=6.0 diff --git a/backend/tests/test_propose_vertical.py b/backend/tests/test_propose_vertical.py new file mode 100644 index 0000000..50baaf7 --- /dev/null +++ b/backend/tests/test_propose_vertical.py @@ -0,0 +1,440 @@ +"""End-to-end integration test for the Slice 1 vertical. + +Stands up the FastAPI app against a mocked Gitea transport that +simulates the meta repo and the propose-to-merge lifecycle. The test +walks the same path a user would: sign in (a forged session cookie +substitutes for the OAuth round-trip, since OAuth itself is not in +scope to mock end-to-end), open a propose modal +(POST /api/rfcs/propose), exercise the bot wrapper through to the +Gitea HTTP layer, merge the PR as an owner, refresh the cache, and +verify the super-draft surfaces in GET /api/rfcs and +GET /api/rfcs/<slug>. + +The mocked Gitea is intentionally narrow — it only honors the +endpoints the slice actually exercises. Adding routes to it as later +slices land is the right shape: the test surface tracks the production +surface. +""" +from __future__ import annotations + +import base64 +import json +import os +import re +import tempfile +from pathlib import Path + +import httpx +import pytest + + +# --------------------------------------------------------------------------- +# Fake Gitea +# --------------------------------------------------------------------------- + + +class FakeGitea: + """A narrow in-memory simulation of the Gitea API the slice uses.""" + + def __init__(self): + # files: (owner, repo, branch, path) -> {"content": str, "sha": str} + self.files: dict[tuple[str, str, str, str], dict] = {} + # branches: (owner, repo) -> {branch_name -> {"sha": str}} + self.branches: dict[tuple[str, str], dict[str, dict]] = {} + # pulls: (owner, repo) -> list[pull-dict] + self.pulls: dict[tuple[str, str], list[dict]] = {} + self._pr_counter = 0 + self._commit_counter = 0 + self._seed_repo("wiggleverse", "meta") + + def _seed_repo(self, owner, repo): + self.branches[(owner, repo)] = {"main": {"sha": "initial"}} + self.pulls[(owner, repo)] = [] + + def _next_sha(self): + self._commit_counter += 1 + return f"sha{self._commit_counter:04d}" + + def handle(self, request: httpx.Request) -> httpx.Response: + path = request.url.path.replace("/api/v1", "", 1) + method = request.method + body = request.read().decode() if request.content else "" + payload = json.loads(body) if body else {} + + # GET /repos/{owner}/{repo} + if method == "GET" and re.fullmatch(r"/repos/[^/]+/[^/]+", path): + return httpx.Response(200, json={"name": path.split("/")[-1]}) + + # GET /repos/{owner}/{repo}/branches/{branch} + m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches/([^/]+)", path) + if method == "GET" and m: + owner, repo, branch = m.groups() + b = self.branches.get((owner, repo), {}).get(branch) + if not b: + return httpx.Response(404, json={"message": "not found"}) + return httpx.Response(200, json={"name": branch, "commit": {"id": b["sha"]}}) + + # POST /repos/{owner}/{repo}/branches + m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches", path) + if method == "POST" and m: + owner, repo = m.groups() + new = payload["new_branch_name"] + old = payload["old_branch_name"] + old_sha = self.branches[(owner, repo)][old]["sha"] + self.branches[(owner, repo)][new] = {"sha": old_sha} + # Copy main's files into the new branch + for (o, r, br, p), data in list(self.files.items()): + if (o, r, br) == (owner, repo, old): + self.files[(owner, repo, new, p)] = dict(data) + return httpx.Response(201, json={"name": new}) + + # GET /repos/{owner}/{repo}/contents/{path}?ref=... + m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path) + if method == "GET" and m: + owner, repo, fpath = m.groups() + ref = request.url.params.get("ref", "main") + key = (owner, repo, ref, fpath) + if key in self.files: + f = self.files[key] + return httpx.Response(200, json={ + "name": fpath.rsplit("/", 1)[-1], + "path": fpath, + "type": "file", + "sha": f["sha"], + "content": base64.b64encode(f["content"].encode()).decode(), + }) + # Directory listing + prefix = fpath.rstrip("/") + "/" + children = [] + for (o, r, br, p), data in self.files.items(): + if (o, r, br) == (owner, repo, ref) and p.startswith(prefix) and "/" not in p[len(prefix):]: + children.append({ + "name": p.rsplit("/", 1)[-1], + "path": p, + "type": "file", + "sha": data["sha"], + }) + if children: + return httpx.Response(200, json=children) + return httpx.Response(404, json={"message": "not found"}) + + # POST /repos/{owner}/{repo}/contents/{path} + m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path) + if method == "POST" and m: + owner, repo, fpath = m.groups() + branch = payload["branch"] + content = base64.b64decode(payload["content"]).decode() + sha = self._next_sha() + self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha} + self.branches[(owner, repo)][branch]["sha"] = sha + return httpx.Response(201, json={"commit": {"sha": sha}}) + + # GET /repos/{owner}/{repo}/pulls?state=... + m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls", path) + if method == "GET" and m: + owner, repo = m.groups() + state = request.url.params.get("state", "open") + items = self.pulls.get((owner, repo), []) + filtered = [p for p in items if (state == "all") or (p["state"] == state)] + return httpx.Response(200, json=filtered) + + # POST /repos/{owner}/{repo}/pulls + if method == "POST" and m: + owner, repo = m.groups() + self._pr_counter += 1 + head_branch = payload["head"] + pr = { + "number": self._pr_counter, + "title": payload["title"], + "body": payload["body"], + "head": {"ref": head_branch, "sha": self.branches[(owner, repo)][head_branch]["sha"]}, + "base": {"ref": payload["base"]}, + "state": "open", + "merged": False, + "merged_at": None, + "closed_at": None, + "created_at": "2026-05-23T00:00:00Z", + "user": {"login": "rfc-bot"}, + } + self.pulls[(owner, repo)].append(pr) + return httpx.Response(201, json=pr) + + # POST /repos/{owner}/{repo}/pulls/{number}/merge + m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls/(\d+)/merge", path) + if method == "POST" and m: + owner, repo, num = m.groups() + for pr in self.pulls[(owner, repo)]: + if pr["number"] == int(num): + head_branch = pr["head"]["ref"] + for (o, r, br, p), data in list(self.files.items()): + if (o, r, br) == (owner, repo, head_branch): + self.files[(owner, repo, "main", p)] = dict(data) + # Real Gitea: state becomes "closed" with merged=true. + pr["state"] = "closed" + pr["merged"] = True + pr["merged_at"] = "2026-05-23T01:00:00Z" + pr["closed_at"] = "2026-05-23T01:00:00Z" + new_sha = self._next_sha() + self.branches[(owner, repo)]["main"]["sha"] = new_sha + return httpx.Response(200, json={"merged": True}) + return httpx.Response(404, json={"message": "not found"}) + + # GET /repos/{owner}/{repo}/hooks + m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/hooks", path) + if method == "GET" and m: + return httpx.Response(200, json=[]) + + # PATCH /repos/{owner}/{repo}/issues/{number} — Gitea close path. + m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/issues/(\d+)", path) + if method == "PATCH" and m: + owner, repo, num = m.groups() + for pr in self.pulls.get((owner, repo), []): + if pr["number"] == int(num) and payload.get("state") == "closed": + pr["state"] = "closed" + pr["closed_at"] = "2026-05-23T02:00:00Z" + return httpx.Response(200, json={"state": "closed"}) + return httpx.Response(200, json={}) + + # POST /repos/{owner}/{repo}/issues/{number}/comments + m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/issues/(\d+)/comments", path) + if method == "POST" and m: + return httpx.Response(201, json={"id": 1, "body": payload.get("body", "")}) + + return httpx.Response(404, json={"message": f"unmocked {method} {path}"}) + + +# --------------------------------------------------------------------------- +# Session helpers — forge a SessionMiddleware cookie directly to skip OAuth. +# --------------------------------------------------------------------------- + + +def _sign_session(session_data: dict, secret: str) -> str: + from itsdangerous import TimestampSigner + data = base64.b64encode(json.dumps(session_data).encode("utf-8")) + signer = TimestampSigner(secret) + return signer.sign(data).decode("utf-8") + + +def sign_in_as(client, *, user_id, gitea_login, display_name, role, email=""): + payload = { + "user": { + "user_id": user_id, + "gitea_id": user_id, + "gitea_login": gitea_login, + "display_name": display_name, + "email": email, + "avatar_url": "", + "role": role, + } + } + cookie = _sign_session(payload, os.environ["SECRET_KEY"]) + client.cookies.set("rfc_session", cookie) + + +def provision_user_row(*, user_id: int, login: str, role: str) -> None: + from app import db + db.conn().execute( + """ + INSERT OR REPLACE INTO users (id, gitea_id, gitea_login, email, display_name, avatar_url, role) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (user_id, user_id, login, f"{login}@test", login.capitalize(), "", role), + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def tmp_env(monkeypatch): + tmpdir = tempfile.mkdtemp(prefix="rfc-app-test-") + db_path = Path(tmpdir) / "test.db" + env = { + "GITEA_URL": "http://gitea.test", + "GITEA_BOT_USER": "rfc-bot", + "GITEA_BOT_TOKEN": "bot-token", + "GITEA_ORG": "wiggleverse", + "META_REPO": "meta", + "OAUTH_CLIENT_ID": "cid", + "OAUTH_CLIENT_SECRET": "csec", + "APP_URL": "http://localhost:8000", + "SECRET_KEY": "test-secret-key-for-cookies", + "DATABASE_PATH": str(db_path), + "OWNER_GITEA_LOGIN": "ben", + "GITEA_WEBHOOK_SECRET": "", + "ENABLED_MODELS": "claude", + } + for k, v in env.items(): + monkeypatch.setenv(k, v) + yield env + + +@pytest.fixture +def app_with_fake_gitea(tmp_env, monkeypatch): + fake = FakeGitea() + real_client_cls = httpx.AsyncClient + + def patched_client(*args, **kwargs): + kwargs["transport"] = httpx.MockTransport(fake.handle) + return real_client_cls(*args, **kwargs) + + monkeypatch.setattr("app.gitea.httpx.AsyncClient", patched_client) + + # The db module memoizes its connection — reset across tests so each + # test gets the tmpdir db its env points at, not a previous test's. + from app import db + if db._CONN is not None: + db._CONN.close() + db._CONN = None + + from app.main import create_app + app = create_app() + return app, fake + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_propose_to_super_draft_vertical(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + + with TestClient(app) as client: + # The catalog is empty before anything happens. + r = client.get("/api/rfcs") + assert r.status_code == 200 + assert r.json()["items"] == [] + + # A contributor proposes a new RFC. + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test") + r = client.post("/api/rfcs/propose", json={ + "title": "Open Human Model", + "slug": "open-human-model", + "pitch": "A shared definition of what we mean by *human*.", + "tags": ["identity", "schema"], + }) + assert r.status_code == 200, r.text + pr_number = r.json()["pr_number"] + assert r.json()["slug"] == "open-human-model" + + # The proposal surfaces on the pending-ideas list. + r = client.get("/api/proposals") + items = r.json()["items"] + assert len(items) == 1 + assert items[0]["slug"] == "open-human-model" + assert items[0]["pr_number"] == pr_number + + # A contributor cannot merge. + r = client.post(f"/api/proposals/{pr_number}/merge") + assert r.status_code == 403 + + # Switch to the owner. The pending-idea view exposes the merge affordance. + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test") + r = client.get(f"/api/proposals/{pr_number}") + assert r.status_code == 200, r.text + proposal = r.json() + assert proposal["entry"]["title"] == "Open Human Model" + assert proposal["entry"]["state"] == "super-draft" + assert proposal["affordances"]["merge"] is True + + # Owner merges. The catalog picks up the new super-draft. + r = client.post(f"/api/proposals/{pr_number}/merge") + assert r.status_code == 200, r.text + assert r.json()["slug"] == "open-human-model" + + r = client.get("/api/rfcs") + items = r.json()["items"] + assert len(items) == 1 + assert items[0]["slug"] == "open-human-model" + assert items[0]["state"] == "super-draft" + assert "identity" in items[0]["tags"] + + # The super-draft view renders the body. + r = client.get("/api/rfcs/open-human-model") + assert r.status_code == 200 + view = r.json() + assert view["state"] == "super-draft" + assert "shared definition" in view["body"] + + # The pending-ideas list no longer carries the merged proposal. + r = client.get("/api/proposals") + assert r.json()["items"] == [] + + # The bot's actions are recorded in the audit log per §6.5. + actions = db.conn().execute( + "SELECT action_kind, on_behalf_of FROM actions ORDER BY id" + ).fetchall() + kinds = [(a["action_kind"], a["on_behalf_of"]) for a in actions] + assert ("propose_rfc", "alice") in kinds + assert ("merge_proposal", "ben") in kinds + + +def test_slug_uniqueness_enforced(app_with_fake_gitea): + from fastapi.testclient import TestClient + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=5, login="alice", role="contributor") + sign_in_as(client, user_id=5, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/propose", json={ + "title": "First", "slug": "first", "pitch": "p", "tags": [], + }) + assert r.status_code == 200, r.text + r = client.post("/api/rfcs/propose", json={ + "title": "First Again", "slug": "first", "pitch": "p", "tags": [], + }) + assert r.status_code == 409 + + +def test_invalid_slug_rejected(app_with_fake_gitea): + from fastapi.testclient import TestClient + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=7, login="alice", role="contributor") + sign_in_as(client, user_id=7, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/propose", json={ + "title": "Bad slug", "slug": "Bad Slug!", "pitch": "p", "tags": [], + }) + assert r.status_code == 422 + + +def test_anonymous_cannot_propose(app_with_fake_gitea): + from fastapi.testclient import TestClient + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + r = client.post("/api/rfcs/propose", json={ + "title": "A", "slug": "a", "pitch": "p", "tags": [], + }) + assert r.status_code == 401 + + +def test_withdraw_by_proposer_works(app_with_fake_gitea): + from fastapi.testclient import TestClient + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=9, login="alice", role="contributor") + provision_user_row(user_id=10, login="bob", role="contributor") + sign_in_as(client, user_id=9, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/propose", json={ + "title": "X", "slug": "x", "pitch": "p", "tags": [], + }) + pr_number = r.json()["pr_number"] + + # A different contributor cannot withdraw someone else's proposal. + sign_in_as(client, user_id=10, gitea_login="bob", display_name="Bob", role="contributor") + r = client.post(f"/api/proposals/{pr_number}/withdraw") + assert r.status_code == 403 + + # The proposer can. + sign_in_as(client, user_id=9, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post(f"/api/proposals/{pr_number}/withdraw") + assert r.status_code == 200, r.text + r = client.get("/api/proposals") + assert r.json()["items"] == [] diff --git a/docs/DEV.md b/docs/DEV.md new file mode 100644 index 0000000..3185d29 --- /dev/null +++ b/docs/DEV.md @@ -0,0 +1,153 @@ +# Build notes + +The slicing plan for the v1 build, the current state of the codebase, +and the next slice's brief. + +## The slicing plan + +Eight slices carry §§1–15 of [`SPEC.md`](../SPEC.md) end-to-end. The +build does not extend the spec; spec corrections during the build are +rare and surgical and live in the appropriate numbered section per +§19.3's working agreement. + +1. **Repository scaffolding + propose-to-super-draft vertical.** The + chokepoint that every Git operation flows through (§1 bot wrapper), + the §4 cache machinery (webhook + reconciler), the §5 schema, Gitea + OAuth + user provisioning, the minimal §7 catalog, and one + end-to-end vertical: propose → idea PR → merge → super-draft view. +2. **The active-RFC view per §8 in full.** Editor, branch creation, + per-branch chat with AI participation (the §18 `<change>` protocol), + the change-card panel, accept/decline/edit, manual-edit flushes, + sub-threads, flags, DiffView. +3. **The PR flow per §10.** Open, review surface (diff + compressed + chat), the §10.3 seen-cursor, §10.4 review threads, merge, + post-merge, §10.9 conflict resolution. +4. **Super-draft body editing per §9.5 + §9.6.** Meta-repo edit + branches as the unit of work; everything from §8 inherits. +5. **Graduation per §13.** The dialog, the five-step transactional + sequence, rollback, the pre-graduation history affordance. +6. **Notifications per §15.** Last, because every other surface + produces signals the inbox receives — notification correctness + depends on the producers being in place first. +7. **The §14 chrome.** Landing page polish, the `/philosophy` route, + the persistent About link. +8. **Hardening.** End-to-end tests, dev/prod deployment shape, + the §12 30/90 branch-hygiene timers. + +## State of the codebase + +### Slice 1 — shipped + +The repository scaffolding (`backend/`, `frontend/`, `scripts/`, +`docs/`), the §5 schema as numbered migrations under +`backend/migrations/`, the §1 bot wrapper (`app/bot.py`) that is the +single chokepoint every Git write flows through, Gitea OAuth and the +§6.1 user-provisioning row in `users`, the §4.1 webhook receiver and +the §4.1 periodic reconciler (both writing to the cache; user actions +never do), the §7 left pane (catalog list, search, sort, state-filter +chips, pending-ideas disclosure), and one end-to-end vertical: propose +→ idea PR opens → owner merges → super-draft appears in the catalog → +super-draft view renders the body. + +The §17 endpoints exercised so far: + +| Method | Path | § | +| ------ | -------------------------------------- | ------- | +| GET | `/api/auth/me` | §6 | +| GET | `/api/rfcs` | §7, §17 | +| GET | `/api/rfcs/{slug}` | §17 | +| GET | `/api/proposals` | §17 | +| GET | `/api/proposals/{pr_number}` | §17 | +| POST | `/api/rfcs/propose` | §9.1 | +| POST | `/api/proposals/{pr_number}/merge` | §9.3 | +| POST | `/api/proposals/{pr_number}/decline` | §9.3 | +| POST | `/api/proposals/{pr_number}/withdraw` | §9.3 | +| POST | `/api/webhooks/gitea` | §4.1 | +| GET | `/auth/login` / `/auth/callback` / `/auth/logout` | §18 | + +### What's deferred from slice 1 + +These were on the §9.1 spec but pushed to Slice 2 because they belong +with surfaces that haven't been built yet: + +- The propose modal's **AI-suggested tags** (§9.1) — the AI surface + lands with Slice 2's chat wiring. The tag chip input works manually + in the meantime. +- The propose modal's **AI-drafted PR description** (§9.2) — same + reason. The PR description is the pitch text for now. +- The decline ceremony's **two-step composer-then-preview dialog** + (§9.3) — the single-step required-comment input is in place; the + preview-and-confirm beat is the kind of UX polish that the §19.2 + topic "pending-idea view's interaction design (remainder)" should + pick up alongside the merge-confirmation ceremony. +- The §9.3 **pre-merge chat thread on a pending-idea view** and the + migration of those threads to the super-draft on merge — depends + on Slice 2's chat infrastructure. + +These are deferred in the build's working sense — surfaces exist in +the spec, but they share infrastructure that's wired in a later slice +and would otherwise have to be wired twice. + +## Environment notes + +- **Python 3.13.** Earlier 3.11+ should also work; 3.13 is what the + build session ran on. +- **Node 20+** for the frontend. +- **Local Gitea on port 3000.** Anything that exposes the Gitea v1 + REST API works. If you tunnel Gitea elsewhere (e.g. a container, + a Codespace), re-run `scripts/seed_meta_repo.py` so the webhook + re-registers against the right `APP_URL`. + +## Conventions + +- **Bot writes only via `app/bot.py`.** If a module wants to call + `app/gitea.py`'s write methods directly, the spec is right and + the module is wrong — the wrapper is the chokepoint that makes + the §6.5 `On-behalf-of:` trailer and the §6 authorization both + consistent. +- **Cache writes only from `app/cache.py`.** User actions trigger + Git operations via the bot; the cache learns about them when the + webhook arrives (or the next reconciler sweep), and never before. + This invariant is what makes §4's "Git is truth" claim hold + operationally. +- **Spec corrections during the build are rare and surgical.** When + running code reveals the spec was wrong at a structural level (per + §19.3's working agreement), the correction lands in the appropriate + numbered section with a brief note explaining what running code + revealed. Spec extensions during the build are not in scope — + they accumulate in §19.2. +- **§16 stays deferred.** Body full-text search, per-RFC model + picker, funder role, persistent accepted-change markup, slug + renames — these are not shipped in any slice. They earn their own + topic sessions when use surfaces evidence they matter. + +## Next slice + +**Slice 2: the active-RFC view per §8.** + +The active-RFC view inherits the three-column shape (§8.1), opens +on `main` in discuss mode by default (§8.2), supports the §8.3 +discuss-vs-contribute mode flip on non-main branches, hosts §8.4's +per-branch chat with AI participation (§18's `<change>` protocol +parsing into `changes` rows per §8.6), the §8.8 change-card panel +with §8.9's accept / decline / edit-before-accept resolution, the +§8.10 tracked-change markup and DiffView toggle, the §8.11 manual- +edit flushes, the §8.12 range and paragraph sub-threads, the §8.13 +flag affordance, and the §8.14 discuss-mode buffer. + +The carryover assets that belong to Slice 2 are in the prototype +under `/Users/benstull/projects/wiggleverse/rfc-app-prototype/`: + +- `frontend/src/components/Editor.jsx`, `ChatPanel.jsx`, + `ChangePanel.jsx`, `PromptBar.jsx`, `SelectionTooltip.jsx`, + `DiffView.jsx`, `ModelPicker.jsx` — Tiptap config, the + `<change>` parser, the selection-quote machinery, the + model-picker UX. +- `backend/providers.py`, `backend/chat.py` — the multi-provider + abstraction and the SSE-streaming chat layer. + +These are §18 carryovers; reuse the working code rather than +rewriting. The prototype's *data model* and *permission shape* do +not carry; this codebase's `threads`, `thread_messages`, `changes`, +`changes.thread_id`, the §6 four-role model, and the per-branch +chat thread are the canonical shape for Slice 2 to wire against. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e57eaf0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Wiggleverse RFCs + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..5e86e3a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1707 @@ +{ + "name": "rfc-app-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rfc-app-frontend", + "version": "0.1.0", + "dependencies": { + "@tiptap/extension-placeholder": "^3.5.0", + "@tiptap/pm": "^3.5.0", + "@tiptap/react": "^3.5.0", + "@tiptap/starter-kit": "^3.5.0", + "marked": "^18.0.4", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.12" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT", + "optional": true + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tiptap/core": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.6.tgz", + "integrity": "sha512-MRB3pHz4Oxqmcawh0cQ5iOGdY5xtNYp/1CoK7hdTLzw5K0C6/gTC2VvanB1R4INaB6EpBkxG/GiWkVirDRnuXw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.23.6" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.23.6.tgz", + "integrity": "sha512-2RmnqNqTltZ2k1F7IfjoDNs935Uq4rRDR7d98mqkg3OlDktcQIyBpv0t9dTay6H5bkQeZUuS8ogK2S1E8Edjug==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.23.6.tgz", + "integrity": "sha512-1LMhjnytdbbhWHSoOwnLxZAOQZWPkKyXVCNmaIk0Mhi4tLPUXptG4qKS5sVYTCveE5H6IBPFrbgBFi5dMI6krA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.23.6.tgz", + "integrity": "sha512-Mwkyp9LkDHFbqmWRIkp63FinRxFu3ajC4qSb9t4mnHsb4kAdbNLLsGtbFg+le0SWk4CxGwAOwM7SzeJ+6UGqCA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6", + "@tiptap/pm": "3.23.6" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.23.6.tgz", + "integrity": "sha512-RMRgfXZykr/13X8UBOwvpgysVOo9KchwqMoEbvqQSj4YFfU56iIn59C8sbxiQ1sKfeltUf0wH4fPc0I4iwKqAA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.23.6" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.23.6.tgz", + "integrity": "sha512-KG8KXFYyLrtYvT7AZ1WGV61ofx8pDe5g9pH658MERxqQGii+Pyfc6xkz04l7XeBts/7+571UQp/0O7i/z560TA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.23.6.tgz", + "integrity": "sha512-4kccgcn5yHThxrzsIhJny3EwfEZYIk+BjUCL4uIuzOyWvExtGhZ6JMHVCZeMhI8D1/bX1LNkkAKN5DXPzH4lXQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6", + "@tiptap/pm": "3.23.6" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.23.6.tgz", + "integrity": "sha512-XDAIgG9KcKumFM9KJWUEUhXPbFIhhl47bfy5GknareWTRKke85rcoj/oxKKO9ihLZr8JfpbXjqnS4SCm5yhYPw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.23.6.tgz", + "integrity": "sha512-+XWEoRKf3lXxi7Le1aOM2xU1XHwxICGpXjT3m4QaYqUgIpsq8gQEuso6kVg8DnTD7biKQs6+oIQ0o2b/gTW9WA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.23.6" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.23.6.tgz", + "integrity": "sha512-2kjuDcEq69lEcECl75xqY5MyzUSh2zcC5aLrpwP1WwhJz5bxsIFHiaps5AP6h9R4A+ZBj5b2haay2Y1wDUU3VA==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.23.6", + "@tiptap/pm": "3.23.6" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.23.6.tgz", + "integrity": "sha512-wbKmxXsszxWacEkrHucRpSQbiKjz4fmOebD6OVyL9AcrmlbxNk8vcM3iyh/8cVeRy09XY+morM165t/u7/z4IQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.23.6" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.23.6.tgz", + "integrity": "sha512-KeUm+tkUfIVSX9QM9XOIhaay0Fn36sLKUo5NVYjN3uJaxFvaZXZmTlxdO85OTdgF2P5sqh9LomrIgliaFRGk4w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.23.6.tgz", + "integrity": "sha512-A/0jPhxnUh9THSZymlu0OGPZe1wdFdwHAXnRCmqvYUCwJjrG7LCC/ahzmcj1tcNzI9hgHyuYPSfev8RXYrNu/w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.23.6.tgz", + "integrity": "sha512-hEUlz4H+I64r+TH6LCuNCRgO7JTHncXGmx9+WbU69EOfY8O0ZurcgeJc8HeiAKL+r9YuC1e5YHfFxgCaaC0jlg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6", + "@tiptap/pm": "3.23.6" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.23.6.tgz", + "integrity": "sha512-wol5KdwCPAvpiYhH9PLlvO8ZnJHwZtIboVevrfOGgBcKlXRA3dedR4OAMXHnUtkkzu9KtliLg1+TYzEx4JZG9Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.23.6.tgz", + "integrity": "sha512-KNZz7z7P2/qbQsx5bPAbSPjrKDg1VHsedGlLHJCr8U2VRD5VgmDLkMpkouP1CsDg15qgyUKv/nDib5KgPpLNWA==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6", + "@tiptap/pm": "3.23.6" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.6.tgz", + "integrity": "sha512-z6vj9+Qht2sjdQkyyHcUpsC/yCIZqTrQiyHDhs/HGKrfvoANyAZGpqdNeKf1wSyjIso+27tQuIH5NDfk8ygyNw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6", + "@tiptap/pm": "3.23.6" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.23.6.tgz", + "integrity": "sha512-3zzyhdkUWcHVpXuvy6KiIwjh29rbH6gEDEqPQqHLrl1XGnO9pnShC7pSHctlCDjmcx3O4n9cd4QMtVBlUerbiA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.23.6" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.23.6.tgz", + "integrity": "sha512-x8bPcLViGzg/RAmQM/XtmfqIwQ/Pv9Q8mkd+OgfUiTqjeJqKwVQmiqbLFNa7zw81+H61M+HDU+qGAaQ3vRIMjw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.23.6" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.23.6.tgz", + "integrity": "sha512-1m/wWB/ZtXcmG2vNdiUkCqsOgqv5vBjCv/mVaHhF9OvV+zQS8YDjoWE7zEuT/GgELdT77Xq8lHrn4nCDudB3/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.23.6" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.23.6.tgz", + "integrity": "sha512-+7m58LUSncodjrIyXks4RZ3tLNYrvgT77wRR4l3HnM5OABY3GDsDTqi7c1t1yI29NVOSk/DUacqy6UwYAj1DGg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.23.6.tgz", + "integrity": "sha512-8I6b2aevF74aLgymKMxbDxSLxWA2y+2dh0zZDeI8sRZ2m6WHHes+Kyuuwkq1HIPcR+ZLpbec74cmf6lcL/yvqQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.23.6" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.23.6.tgz", + "integrity": "sha512-oF7FEZ37f15aCe5kPgzGDYf/m+hr7VdQ/Ko/Hds/UM9pX7AG1fdtmRrl6wqkRqDM/incZaC/AQR2/Dpo2VCNGQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.23.6.tgz", + "integrity": "sha512-ipoC2TkIAIOTiF5ByiGgvQB1DqDyfP90wrUB3mohBcgvp7lQnwHszCDGv8dNnmcUek8uXV/uoLu2VXeVQlxjPA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.23.6.tgz", + "integrity": "sha512-P55wGIZGYTVH92Fq0cgI4/O9AhLCaJC3hhxg15RSERP5/YegM9eJHDK/GQ1EE/DvYA+xpYGOV6agKwAUqfA/Iw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.23.6.tgz", + "integrity": "sha512-X09/Db1teB+ifXzDGVVFmOeQRx7wTAayE9/280spxpsHkHZvJ5bHRvWIzUzviMIjbBz+NPDIKYPK7gMfh9iaig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6", + "@tiptap/pm": "3.23.6" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.6.tgz", + "integrity": "sha512-in5CaMaWlJcH2A1q6GJKFtrodE8WLS3M9tIi/f89jPmIVHJShpodC0KZDNyJkrVBQomYk0DEh86Utm6ASXzQww==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-keymap": "^1.2.2", + "prosemirror-model": "^1.24.1", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.38.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.23.6.tgz", + "integrity": "sha512-Tw9KZkYqFMk3vaJAEQKqEYIO/iq3cSJe7OUEGBul4k4GaMQeLItLf5EYhUd0GIPXci1WVVPNntKJsHfX25M37w==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.23.6", + "@tiptap/extension-floating-menu": "^3.23.6" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6", + "@tiptap/pm": "3.23.6", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.23.6.tgz", + "integrity": "sha512-gykwtGWrnWCmtql1hid3opac/KV8zQvOAnu3bTqIqcHrn1FusbUwKmNzavSbfGvcktHM3hFjb35W48JyVLyu/A==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.23.6", + "@tiptap/extension-blockquote": "^3.23.6", + "@tiptap/extension-bold": "^3.23.6", + "@tiptap/extension-bullet-list": "^3.23.6", + "@tiptap/extension-code": "^3.23.6", + "@tiptap/extension-code-block": "^3.23.6", + "@tiptap/extension-document": "^3.23.6", + "@tiptap/extension-dropcursor": "^3.23.6", + "@tiptap/extension-gapcursor": "^3.23.6", + "@tiptap/extension-hard-break": "^3.23.6", + "@tiptap/extension-heading": "^3.23.6", + "@tiptap/extension-horizontal-rule": "^3.23.6", + "@tiptap/extension-italic": "^3.23.6", + "@tiptap/extension-link": "^3.23.6", + "@tiptap/extension-list": "^3.23.6", + "@tiptap/extension-list-item": "^3.23.6", + "@tiptap/extension-list-keymap": "^3.23.6", + "@tiptap/extension-ordered-list": "^3.23.6", + "@tiptap/extension-paragraph": "^3.23.6", + "@tiptap/extension-strike": "^3.23.6", + "@tiptap/extension-text": "^3.23.6", + "@tiptap/extension-underline": "^3.23.6", + "@tiptap/extensions": "^3.23.6", + "@tiptap/pm": "^3.23.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, + "node_modules/marked": { + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", + "integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.7", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.7.tgz", + "integrity": "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.8", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", + "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-router": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", + "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", + "license": "MIT", + "dependencies": { + "react-router": "7.15.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7710cbd --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "rfc-app-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tiptap/extension-placeholder": "^3.5.0", + "@tiptap/pm": "^3.5.0", + "@tiptap/react": "^3.5.0", + "@tiptap/starter-kit": "^3.5.0", + "marked": "^18.0.4", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.12" + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..c22ef12 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,319 @@ +/* Adapted from the prototype's App.css per §18, narrowed to slice-1 surfaces. */ + +.boot { + display: flex; align-items: center; justify-content: center; + height: 100vh; color: #888; font-size: 14px; +} + +.app { height: 100vh; display: flex; flex-direction: column; } + +.app-header { + height: 48px; flex-shrink: 0; + background: #1a1a1a; color: #fff; + display: flex; align-items: center; justify-content: space-between; + padding: 0 20px; +} +.app-brand a { color: #fff; text-decoration: none; font-weight: 600; font-size: 14px; } +.header-right { display: flex; align-items: center; gap: 12px; font-size: 13px; } +.user-name { color: #ddd; } +.user-role-badge { + font-size: 10px; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.06em; + padding: 2px 6px; border-radius: 4px; + background: rgba(255,255,255,0.15); +} +.role-owner { background: #b45309; } +.role-admin { background: #4338ca; } + +.btn-link { + color: #fff; text-decoration: none; + background: rgba(255,255,255,0.15); + border-radius: 6px; padding: 4px 10px; + font-size: 13px; +} +.btn-link:hover { background: rgba(255,255,255,0.25); } + +.app-body { flex: 1; display: flex; overflow: hidden; } + +/* --- Catalog (left pane, §7) --- */ + +.catalog { + width: 320px; flex-shrink: 0; + background: #fff; border-right: 1px solid #e5e5e5; + display: flex; flex-direction: column; + overflow: hidden; +} + +.catalog-search { + padding: 12px 14px; + border-bottom: 1px solid #f0f0ee; +} +.catalog-search input { + width: 100%; border: 1px solid #e5e5e5; border-radius: 6px; + padding: 6px 10px; font-size: 13px; outline: none; +} +.catalog-search input:focus { border-color: #1a1a1a; } + +.catalog-controls { + display: flex; align-items: center; gap: 8px; + padding: 8px 14px; + border-bottom: 1px solid #f0f0ee; + font-size: 12px; color: #555; +} +.catalog-controls select { + border: 1px solid #e5e5e5; border-radius: 4px; + font-size: 12px; padding: 2px 4px; +} + +.catalog-chips { + display: flex; flex-wrap: wrap; gap: 4px; + padding: 6px 14px 10px; + border-bottom: 1px solid #f0f0ee; +} +.chip { + font-size: 11px; + background: #f0f0ee; color: #444; + border: 1px solid transparent; border-radius: 999px; + padding: 2px 9px; cursor: pointer; +} +.chip.active { background: #1a1a1a; color: #fff; border-color: #1a1a1a; } + +.catalog-list { flex: 1; overflow-y: auto; padding: 4px 0; } + +.catalog-row { + display: flex; flex-direction: column; + padding: 8px 14px; + border: none; background: none; text-align: left; cursor: pointer; + border-left: 3px solid transparent; width: 100%; +} +.catalog-row:hover { background: #f7f7f5; } +.catalog-row.active { background: #f0f0ee; border-left-color: #1a1a1a; } +.catalog-row .row-top { display: flex; align-items: center; gap: 6px; } +.row-id { + font-size: 10px; font-weight: 700; + color: #888; text-transform: uppercase; + letter-spacing: 0.04em; +} +.row-id.super { color: #b45309; } +.row-title { font-size: 13px; margin-top: 2px; } +.catalog-row.is-super .row-title { color: #555; } +.row-tags { font-size: 11px; color: #999; margin-top: 2px; } + +.catalog-pending { + border-top: 1px solid #f0f0ee; + flex-shrink: 0; + background: #fafaf8; +} +.pending-header { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 14px; + font-size: 11px; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.06em; + color: #888; + cursor: pointer; + background: none; border: none; width: 100%; text-align: left; +} +.pending-count { + background: #e5e5e5; color: #333; + border-radius: 999px; + padding: 1px 8px; font-size: 11px; +} +.pending-list { padding: 2px 0 8px; } +.pending-row { + display: block; + padding: 6px 14px; + font-size: 13px; color: #444; text-decoration: none; + cursor: pointer; background: none; border: none; text-align: left; width: 100%; +} +.pending-row:hover { background: #fff; color: #1a1a1a; } +.pending-row.active { background: #fff; color: #1a1a1a; } +.pending-row .pending-by { + font-size: 11px; color: #999; margin-top: 1px; +} + +.catalog-footer { + border-top: 1px solid #e5e5e5; + padding: 10px 14px; + flex-shrink: 0; + background: #fff; +} +.btn-propose { + width: 100%; + background: #1a1a1a; color: #fff; + border: none; border-radius: 6px; + padding: 8px 12px; + font-size: 13px; font-weight: 600; + cursor: pointer; +} +.btn-propose:hover { background: #333; } +.btn-propose:disabled { opacity: 0.5; cursor: default; } + +/* --- Main pane --- */ + +.main-pane { + flex: 1; overflow-y: auto; + padding: 32px 48px; +} + +.welcome { max-width: 640px; } +.welcome h1 { font-size: 22px; font-weight: 600; margin: 0 0 16px; } +.welcome p { line-height: 1.7; color: #444; } + +/* --- RFC / Proposal view (read-only for slice 1) --- */ + +.entry-view { max-width: 720px; margin: 0 auto; } +.entry-state-banner { + font-size: 12px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.06em; + color: #b45309; + padding: 6px 10px; + background: #fffbeb; border: 1px solid #fde68a; + border-radius: 6px; + display: inline-block; margin-bottom: 16px; +} +.entry-state-banner.active { color: #166534; background: #f0fdf4; border-color: #bbf7d0; } +.entry-state-banner.declined { color: #991b1b; background: #fef2f2; border-color: #fecaca; } +.entry-state-banner.merged { color: #1e40af; background: #eff6ff; border-color: #bfdbfe; } + +.entry-title { font-size: 26px; font-weight: 700; margin: 0 0 8px; } +.entry-meta { font-size: 12px; color: #999; margin-bottom: 24px; } +.entry-meta .entry-tag { + display: inline-block; + background: #f0f0ee; color: #555; + padding: 1px 8px; border-radius: 999px; + margin-right: 4px; +} +.entry-body { + font-size: 15px; line-height: 1.75; color: #1a1a1a; +} +.entry-body h1, .entry-body h2, .entry-body h3 { font-weight: 600; } +.entry-body h1 { font-size: 22px; margin-top: 24px; } +.entry-body h2 { font-size: 17px; margin-top: 20px; } +.entry-body h3 { font-size: 15px; margin-top: 16px; } +.entry-body p { margin: 0 0 12px; } +.entry-body ul, .entry-body ol { padding-left: 24px; } +.entry-body code { background: #f0f0ee; padding: 1px 5px; border-radius: 3px; font-size: 13px; } + +.entry-actions { + display: flex; gap: 8px; margin: 16px 0 24px; + padding-bottom: 24px; + border-bottom: 1px solid #e5e5e5; +} +.btn-primary { + background: #166534; color: #fff; + border: none; border-radius: 6px; + padding: 7px 14px; + font-size: 13px; font-weight: 600; + cursor: pointer; +} +.btn-primary:hover { background: #14532d; } +.btn-primary:disabled { opacity: 0.5; cursor: default; } +.btn-secondary { + background: #fff; color: #1a1a1a; + border: 1px solid #d4d4d4; border-radius: 6px; + padding: 7px 14px; + font-size: 13px; font-weight: 600; + cursor: pointer; +} +.btn-secondary:hover { background: #f7f7f5; } +.btn-danger { + background: #fff; color: #991b1b; + border: 1px solid #fecaca; border-radius: 6px; + padding: 7px 14px; + font-size: 13px; font-weight: 600; + cursor: pointer; +} +.btn-danger:hover { background: #fef2f2; } + +/* --- Modals --- */ + +.modal-overlay { + position: fixed; inset: 0; + background: rgba(20, 20, 20, 0.4); + display: flex; align-items: center; justify-content: center; + z-index: 100; +} +.modal { + background: #fff; + border-radius: 10px; + box-shadow: 0 12px 40px rgba(0,0,0,0.15); + width: 560px; max-width: calc(100vw - 40px); + max-height: calc(100vh - 80px); + display: flex; flex-direction: column; +} +.modal-header { + padding: 18px 20px; + border-bottom: 1px solid #f0f0ee; + display: flex; align-items: center; justify-content: space-between; +} +.modal-header h2 { margin: 0; font-size: 17px; font-weight: 600; } +.modal-close { + background: none; border: none; font-size: 22px; cursor: pointer; + color: #999; line-height: 1; padding: 0; +} +.modal-body { + padding: 20px; + overflow-y: auto; + flex: 1; +} +.modal-body label { + display: block; + font-size: 12px; font-weight: 600; + color: #555; + text-transform: uppercase; letter-spacing: 0.05em; + margin-bottom: 4px; +} +.modal-body input, .modal-body textarea { + width: 100%; + border: 1px solid #d4d4d4; border-radius: 6px; + padding: 8px 10px; + font-size: 14px; font-family: inherit; + outline: none; margin-bottom: 14px; +} +.modal-body input:focus, .modal-body textarea:focus { border-color: #1a1a1a; } +.modal-body textarea { resize: vertical; min-height: 100px; } +.field-help { + font-size: 12px; color: #999; + margin-top: -10px; margin-bottom: 14px; +} +.field-error { + font-size: 12px; color: #991b1b; + margin-top: -10px; margin-bottom: 14px; +} +.modal-actions { + border-top: 1px solid #f0f0ee; + padding: 14px 20px; + display: flex; justify-content: flex-end; gap: 10px; +} + +/* --- Landing page (pre-login, §14.1) --- */ + +.landing { + height: 100vh; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + padding: 40px; + text-align: center; +} +.landing h1 { font-size: 28px; font-weight: 700; margin: 0 0 8px; } +.landing .subtitle { font-size: 16px; color: #666; margin: 0 0 28px; } +.landing .pitch { + max-width: 540px; + line-height: 1.7; color: #333; + font-size: 15px; + margin: 0 0 28px; +} +.landing .btn-signin { + background: #1a1a1a; color: #fff; + border: none; border-radius: 8px; + padding: 10px 20px; + font-size: 14px; font-weight: 600; + text-decoration: none; +} +.landing .btn-signin:hover { background: #333; } +.landing .secondary-link { + margin-top: 14px; + font-size: 13px; + color: #666; text-decoration: none; +} +.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..7f8e0b0 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react' +import { Routes, Route, Link, useNavigate } from 'react-router-dom' +import { getMe } from './api' +import Catalog from './components/Catalog.jsx' +import RFCView from './components/RFCView.jsx' +import ProposalView from './components/ProposalView.jsx' +import ProposeModal from './components/ProposeModal.jsx' +import Landing from './components/Landing.jsx' +import './App.css' + +export default function App() { + const [me, setMe] = useState(null) + const [loading, setLoading] = useState(true) + const [proposeOpen, setProposeOpen] = useState(false) + const [catalogVersion, setCatalogVersion] = useState(0) + const navigate = useNavigate() + + useEffect(() => { + getMe() + .then(setMe) + .catch(() => setMe({ authenticated: false })) + .finally(() => setLoading(false)) + }, []) + + if (loading) { + return
Loading…
+ } + + if (!me?.authenticated) { + return + } + + return ( +
+
+
+ Wiggleverse RFCs +
+
+ {me.user.display_name} + {me.user.role} + Sign out +
+
+
+ setProposeOpen(true)} + version={catalogVersion} + /> +
+ + } /> + } /> + setCatalogVersion(v => v + 1)} />} /> + +
+
+ {proposeOpen && ( + setProposeOpen(false)} + onSubmitted={({ pr_number }) => { + setProposeOpen(false) + setCatalogVersion(v => v + 1) + navigate(`/proposals/${pr_number}`) + }} + /> + )} +
+ ) +} + +function Welcome({ viewer }) { + return ( +
+

Welcome, {viewer.display_name}.

+

+ The catalog on the left lists every super-draft and active RFC in the + framework. Open one to read the canonical body, or use{' '} + Propose New RFC at the bottom of the catalog to open an + idea PR against the meta repository. +

+

+ Slice 1 of the build is in place: propose → idea PR → owner merges → + super-draft appears in the catalog → super-draft view renders. The + revision flow, per-branch chat, AI participation, and the PR surface + land in subsequent slices. +

+
+ ) +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..07b70d1 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,70 @@ +// api.js — every backend call lives here. +// +// All write requests pass {credentials: 'include'} implicitly because +// the dev proxy and the production deploy serve the API from the same +// origin as the frontend. If you split origins later, change here. + +async function jsonOrThrow(res) { + if (!res.ok) { + let detail = '' + try { + const body = await res.json() + detail = body.detail || JSON.stringify(body) + } catch { + detail = await res.text() + } + const error = new Error(detail || `HTTP ${res.status}`) + error.status = res.status + throw error + } + return res.json() +} + +export async function getMe() { + const res = await fetch('/api/auth/me') + return jsonOrThrow(res) +} + +export async function listRFCs() { + return jsonOrThrow(await fetch('/api/rfcs')) +} + +export async function getRFC(slug) { + return jsonOrThrow(await fetch(`/api/rfcs/${slug}`)) +} + +export async function listProposals() { + return jsonOrThrow(await fetch('/api/proposals')) +} + +export async function getProposal(prNumber) { + return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`)) +} + +export async function proposeRFC({ title, slug, pitch, tags }) { + const res = await fetch('/api/rfcs/propose', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, slug, pitch, tags: tags || [] }), + }) + return jsonOrThrow(res) +} + +export async function mergeProposal(prNumber) { + const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' }) + return jsonOrThrow(res) +} + +export async function declineProposal(prNumber, comment) { + const res = await fetch(`/api/proposals/${prNumber}/decline`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ comment }), + }) + return jsonOrThrow(res) +} + +export async function withdrawProposal(prNumber) { + const res = await fetch(`/api/proposals/${prNumber}/withdraw`, { method: 'POST' }) + return jsonOrThrow(res) +} diff --git a/frontend/src/components/Catalog.jsx b/frontend/src/components/Catalog.jsx new file mode 100644 index 0000000..0ca41ed --- /dev/null +++ b/frontend/src/components/Catalog.jsx @@ -0,0 +1,155 @@ +// Catalog.jsx — the §7 left pane. +// +// One scrollable flat list of every super-draft and active entry, +// state-styled rather than grouped. Filter chips are AND-combined per +// §7.1; search is fuzzy over title + slug + id. The "Pending ideas" +// disclosure per §7.3 lives at the bottom, above the "+ Propose New RFC" +// button. + +import { useEffect, useMemo, useState } from 'react' +import { useParams, Link } from 'react-router-dom' +import { listRFCs, listProposals } from '../api' + +const STATE_CHIPS = [ + { id: 'super-draft', label: 'Super-draft' }, + { id: 'active', label: 'Active' }, +] + +const SORT_OPTIONS = [ + { id: 'recent', label: 'Recently active' }, + { id: 'title', label: 'Title' }, + { id: 'id', label: 'ID' }, + { id: 'state', label: 'State' }, +] + +export default function Catalog({ onProposeRFC, version }) { + const [rfcs, setRfcs] = useState([]) + const [proposals, setProposals] = useState([]) + const [search, setSearch] = useState('') + const [sort, setSort] = useState('recent') + const [activeChips, setActiveChips] = useState(new Set()) + const [pendingOpen, setPendingOpen] = useState(true) + const { slug, prNumber } = useParams() + + useEffect(() => { + listRFCs().then(d => setRfcs(d.items)).catch(() => setRfcs([])) + listProposals().then(d => setProposals(d.items)).catch(() => setProposals([])) + }, [version]) + + const filtered = useMemo(() => { + const needle = search.trim().toLowerCase() + let items = rfcs.filter(r => { + if (activeChips.size > 0 && !activeChips.has(r.state)) return false + if (!needle) return true + const hay = [r.title, r.slug, r.id || ''].join(' ').toLowerCase() + return hay.includes(needle) + }) + items = [...items].sort((a, b) => { + // Starred items pin to the top of the current sort per §7.2. + if (a.starred_by_me !== b.starred_by_me) return a.starred_by_me ? -1 : 1 + if (sort === 'title') return a.title.localeCompare(b.title) + if (sort === 'id') return (a.id || 'zzz').localeCompare(b.id || 'zzz') + if (sort === 'state') return a.state.localeCompare(b.state) + // recent + return (b.last_active_at || '').localeCompare(a.last_active_at || '') + }) + return items + }, [rfcs, search, sort, activeChips]) + + function toggleChip(id) { + const next = new Set(activeChips) + next.has(id) ? next.delete(id) : next.add(id) + setActiveChips(next) + } + + return ( + + ) +} diff --git a/frontend/src/components/Landing.jsx b/frontend/src/components/Landing.jsx new file mode 100644 index 0000000..8557824 --- /dev/null +++ b/frontend/src/components/Landing.jsx @@ -0,0 +1,24 @@ +// Landing.jsx — §14.1's pre-login surface. +// +// Title, subtitle, the short-form pitch from PHILOSOPHY.md, then the +// single primary action: "Sign in with Gitea." The visual design is +// deferred per §14.4; the structural commitments are here. + +export default function Landing() { + return ( +
+

Wiggleverse RFCs

+

A standards process for shared meaning between humans and machines.

+

+ Large language models work brilliantly with programming languages because every + word in Python or C has a definitive meaning enforced by tooling. They struggle + with natural language because no such dictionary exists for words like + consent, trait, or agency — words that do enormous + work in any system that interacts with humans. The Wiggleverse RFC framework is + a standardization process for that vocabulary. Build the dictionary first. +

+ Sign in with Gitea + Read the full philosophy → +
+ ) +} diff --git a/frontend/src/components/ProposalView.jsx b/frontend/src/components/ProposalView.jsx new file mode 100644 index 0000000..2567bec --- /dev/null +++ b/frontend/src/components/ProposalView.jsx @@ -0,0 +1,168 @@ +// ProposalView.jsx — §9.3 pending-idea view. +// +// Renders the proposed entry's body and frontmatter (read-only) with a +// status banner ("Pending idea — awaiting review"). The header strip +// carries Merge / Decline / Withdraw per the viewer's affordances. The +// decline two-step composer-then-preview from §9.3 ships in Slice 1 +// as a single-step required-comment input; the preview-confirm ceremony +// can land with the rest of §9.3's UX polish in the §19.2 "pending-idea +// view's interaction design (remainder)" topic. + +import { useEffect, useState } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { marked } from 'marked' +import { getProposal, mergeProposal, declineProposal, withdrawProposal } from '../api' + +export default function ProposalView({ viewer, onChange }) { + const { prNumber } = useParams() + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [acting, setActing] = useState(false) + const [declineOpen, setDeclineOpen] = useState(false) + const [declineComment, setDeclineComment] = useState('') + const navigate = useNavigate() + + function refresh() { + setData(null); setError(null) + getProposal(prNumber).then(setData).catch(err => setError(err.message)) + } + + useEffect(refresh, [prNumber]) + + if (error) return

Error: {error}

+ if (!data) return
Loading…
+ + const isOpen = data.state === 'open' + + async function doMerge() { + setActing(true) + try { + const { slug } = await mergeProposal(prNumber) + onChange?.() + navigate(`/rfc/${slug}`) + } catch (e) { + setError(e.message) + setActing(false) + } + } + + async function doDecline() { + if (!declineComment.trim()) return + setActing(true) + try { + await declineProposal(prNumber, declineComment.trim()) + onChange?.() + refresh() + setDeclineOpen(false) + setDeclineComment('') + } catch (e) { + setError(e.message) + } finally { + setActing(false) + } + } + + async function doWithdraw() { + if (!confirm('Withdraw this proposal? The PR will be closed; the conversation stays attached as historical record.')) return + setActing(true) + try { + await withdrawProposal(prNumber) + onChange?.() + refresh() + } catch (e) { + setError(e.message) + } finally { + setActing(false) + } + } + + return ( +
+
+ {isOpen ? 'Pending idea — awaiting review' + : data.state === 'merged' ? 'Merged — now a super-draft' + : 'Closed'} +
+ +

+ {data.entry?.title || data.title.replace(/^Propose:\s*/, '')} +

+ +
+ PR #{data.pr_number} + {data.opened_by && <> · proposed by @{data.opened_by}} + {data.opened_at && <> · {new Date(data.opened_at).toLocaleDateString()}} + {data.entry?.tags?.length > 0 && ( +
+ {data.entry.tags.map(t => {t})} +
+ )} +
+ + {isOpen && ( +
+ {data.affordances.merge && ( + + )} + {data.affordances.decline && ( + + )} + {data.affordances.withdraw && ( + + )} +
+ )} + + {declineOpen && ( +
{ if (e.target === e.currentTarget) setDeclineOpen(false) }}> +
+
+

Decline proposal

+ +
+
+ + +
+ sonnet-4.5 + + + +
+
+
+
+ + + + + + + + diff --git a/scripts/seed_meta_repo.py b/scripts/seed_meta_repo.py new file mode 100755 index 0000000..20b6f55 --- /dev/null +++ b/scripts/seed_meta_repo.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Seed a fresh meta repository on a local Gitea instance. + +Creates `/` if it does not exist, seeds it with the +hand-authored files §2 describes (README.md, PHILOSOPHY.md, +CONTRIBUTING.md, the workflow file, and an empty rfcs/ directory), +and registers the webhook the app needs per §4.1. + +Run this once after standing up Gitea + the bot account + the .env. +Re-running is safe; everything is upsert-shaped. + +Usage: + cd backend && .venv/bin/python ../scripts/seed_meta_repo.py +""" +from __future__ import annotations + +import asyncio +import base64 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "backend")) + +from app.config import load_config # noqa: E402 +from app.gitea import Gitea, GiteaError # noqa: E402 + + +PHILOSOPHY_PATH = Path(__file__).resolve().parent.parent / "PHILOSOPHY.md" + +README_HEADER = """# Wiggleverse RFCs + +*A standards process for shared meaning between humans and machines.* + +This repository is the meta repo for the Wiggleverse RFC framework — the +authoritative directory of every RFC in the system, super-drafts and +graduated entries alike. Every active or in-progress RFC has exactly one +markdown file in `rfcs/` describing it; once graduated, the RFC itself +lives in its own dedicated repository, linked from its entry here. + +See [PHILOSOPHY.md](./PHILOSOPHY.md) for the full statement of why this +framework exists. + + +*The index below is regenerated by CI on every merge to main.* + +""" + +CONTRIBUTING = """# Contributing to the Wiggleverse RFC framework + +All contribution flows through the RFC app, which talks to this Gitea +instance on your behalf via a single bot service account. Raw `git +clone` plus `git push` is not a supported contribution path. + +To propose a new RFC, sign in at the app and use the **"+ Propose New +RFC"** affordance at the bottom of the catalog. Your proposal opens a +PR against this repository adding one file under `rfcs/`. An owner or +admin reviews and either merges (creating the super-draft) or declines +(with a comment to you). + +See the app for the full revision, conversation, and graduation flows. +""" + +ACTIONS_WORKFLOW = """# Regenerates README.md's index section on every merge to main. +# See §2 of SPEC.md. Implementation is a follow-up — this workflow +# file is present as a marker so the meta repo's shape matches the +# spec from day one. +name: regenerate-readme-index + +on: + push: + branches: [main] + +jobs: + noop: + runs-on: ubuntu-latest + steps: + - run: echo "Index regeneration is a follow-up (see SPEC §2)" +""" + + +async def main() -> None: + config = load_config() + gitea = Gitea(config) + try: + await ensure_repo(config, gitea) + await seed_files(config, gitea) + await ensure_webhook(config, gitea) + finally: + await gitea.close() + + +async def ensure_repo(config, gitea: Gitea) -> None: + existing = await gitea.get_repo(config.gitea_org, config.meta_repo) + if existing: + print(f"meta repo {config.meta_repo_full} already exists") + return + print(f"creating meta repo {config.meta_repo_full}") + # Create unauto-initialized so we control the initial commit. + await gitea._request( # noqa: SLF001 — bootstrap-only direct call + "POST", + f"/orgs/{config.gitea_org}/repos", + json={ + "name": config.meta_repo, + "description": "Wiggleverse RFC framework — meta repository", + "private": False, + "auto_init": True, + "default_branch": "main", + }, + ) + + +async def seed_files(config, gitea: Gitea) -> None: + """Write README.md, PHILOSOPHY.md, CONTRIBUTING.md, and the workflow. + + Each file is created if missing, left alone if present — re-running + the seed will not overwrite manual edits made post-bootstrap. + """ + files = { + "PHILOSOPHY.md": PHILOSOPHY_PATH.read_text() if PHILOSOPHY_PATH.exists() else "(placeholder)\n", + "README.md": README_HEADER, + "CONTRIBUTING.md": CONTRIBUTING, + ".gitea/workflows/regenerate-readme-index.yml": ACTIONS_WORKFLOW, + "rfcs/.gitkeep": "", + } + for path, content in files.items(): + existing = await gitea.get_contents(config.gitea_org, config.meta_repo, path, ref="main") + if existing: + print(f" {path} already present — skipping") + continue + print(f" seeding {path}") + await gitea.create_file( + config.gitea_org, + config.meta_repo, + path, + content=content, + message=f"Bootstrap: add {path}", + branch="main", + ) + + +async def ensure_webhook(config, gitea: Gitea) -> None: + if not config.webhook_secret: + print("skipping webhook setup (GITEA_WEBHOOK_SECRET not set)") + return + url = f"{config.app_url}/api/webhooks/gitea" + print(f"ensuring webhook on {config.meta_repo_full} -> {url}") + await gitea.ensure_webhook( + config.gitea_org, + config.meta_repo, + url=url, + secret=config.webhook_secret, + events=["push", "pull_request", "create", "delete", "repository"], + ) + + +if __name__ == "__main__": + asyncio.run(main())