diff --git a/.github/workflows/app-token-smoke.yml b/.github/workflows/app-token-smoke.yml deleted file mode 100644 index 2baabd4e..00000000 --- a/.github/workflows/app-token-smoke.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: app-token-smoke -on: - workflow_dispatch: -permissions: - contents: read - actions: read - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Mint GitHub App token (official) - id: mint - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY_PEM }} - installation-id: ${{ secrets.APP_INSTALLATION_ID }} - - - name: Install jq (ensure present) - run: | - sudo apt-get update -y - sudo apt-get install -y jq - - - name: Show who we are (safe) - env: - TOKEN: ${{ steps.mint.outputs.token }} - run: | - set -e - echo "Token tail: ${TOKEN: -8}" - curl -s -H "Authorization: Bearer ${TOKEN}" -H "Accept: application/vnd.github+json" https://api.github.com/user | jq -r '.login,.type' diff --git a/.github/workflows/ci_docs.yml b/.github/workflows/ci_docs.yml index 0bcf5c35..fe137263 100644 --- a/.github/workflows/ci_docs.yml +++ b/.github/workflows/ci_docs.yml @@ -5,20 +5,33 @@ on: branches: [main] paths: - '**/*.md' + - '.markdownlint*' + - '.lychee*' pull_request: branches: [main] paths: - '**/*.md' + - '.markdownlint*' + - '.lychee*' jobs: docs-lint: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 8 # Reduced from 10 minutes steps: - name: Checkout uses: actions/checkout@v4 + # Cache lychee for faster link checking + - name: Cache lychee + uses: actions/cache@v4 + with: + path: ~/.cache/lychee + key: lychee-${{ runner.os }}-${{ hashFiles('**/*.md') }} + restore-keys: | + lychee-${{ runner.os }}- + # Markdown style/lint - name: markdownlint uses: DavidAnson/markdownlint-cli2-action@v16 @@ -26,12 +39,16 @@ jobs: globs: | **/*.md - # Link checker + # Link checker with caching - name: Link check uses: lycheeverse/lychee-action@v1 with: args: > - --no-progress --verbose --exclude-mail --accept 200,204 + --no-progress --verbose --exclude-mail --accept 200,204,429 + --cache --max-cache-age 1d --include **/*.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Docs validation complete + run: echo "DOCS=OK" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c0b07797..28414e97 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,9 +4,19 @@ name: lint on: workflow_dispatch: pull_request: + paths: + - '.github/workflows/**' + - 'scripts/**' + - '**.yml' + - '**.yaml' push: branches: - main + paths: + - '.github/workflows/**' + - 'scripts/**' + - '**.yml' + - '**.yaml' jobs: lint: @@ -15,16 +25,29 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install dependencies + - name: Cache system dependencies + uses: actions/cache@v4 + id: sys-cache + with: + path: /usr/local/bin/actionlint + key: actionlint-${{ runner.os }}-v1.6.26 + + - name: Install system dependencies (if not cached) + if: steps.sys-cache.outputs.cache-hit != 'true' run: | - sudo apt-get update + sudo apt-get update -qq sudo apt-get install -y jq yamllint gh curl - - name: Install actionlint + - name: Install actionlint (if not cached) + if: steps.sys-cache.outputs.cache-hit != 'true' run: | curl -sSfL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash -o /tmp/actionlint.sh sudo bash /tmp/actionlint.sh latest /usr/local/bin + + - name: Verify tools + run: | actionlint --version && echo "ACTIONLINT=OK" + yamllint --version && echo "YAMLLINT=OK" - name: Run lint script if: ${{ hashFiles('scripts/lint.sh') != '' }} diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 834c68bf..487ebd99 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -4,9 +4,23 @@ name: smoke on: workflow_dispatch: pull_request: + paths: + - '.github/workflows/**' + - 'scripts/**' + - 'docs/**' + - '**.md' + - '**.yml' + - '**.yaml' push: branches: - main + paths: + - '.github/workflows/**' + - 'scripts/**' + - 'docs/**' + - '**.md' + - '**.yml' + - '**.yaml' schedule: # Daily at 12:00 UTC (05:00 PT) - cron: '0 12 * * *' @@ -25,12 +39,25 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install validation tooling + - name: Cache validation tools + uses: actions/cache@v4 + id: tools-cache + with: + path: | + /usr/local/bin/actionlint + ~/.cache/apt + key: validation-tools-${{ runner.os }}-v1.6.26 + + - name: Install validation tooling (if not cached) + if: steps.tools-cache.outputs.cache-hit != 'true' run: | - sudo apt-get update + sudo apt-get update -qq sudo apt-get install -y jq yamllint gh curl curl -sSfL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash -o /tmp/actionlint.sh sudo bash /tmp/actionlint.sh latest /usr/local/bin + + - name: Verify cached tools + run: | actionlint --version echo "ACTIONLINT=OK" diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc new file mode 100644 index 00000000..5c926fd9 Binary files /dev/null and b/.markdownlint.jsonc differ diff --git a/docs/folder_structure.md b/docs/folder_structure.md deleted file mode 100644 index 2d591fae..00000000 --- a/docs/folder_structure.md +++ /dev/null @@ -1,54 +0,0 @@ -# Folder Structure (VPS Runtime) - -## VPS Server (runtime) -``` -/home/platform/htdocs/platform.local/frontend - ├── docker-compose.yml (builds from Dockerfile; exposes 127.0.0.1:3010) - ├── Dockerfile (Next.js 14 multi-stage build) - └── src/ - ├── pages/ - │ ├── _app.tsx - │ └── signup.tsx - └── styles/ - └── globals.css -``` - -## Runtime files -- `/root/ddp.env` (Compose env_file — DB credentials, secrets) -- `/var/www/html/` (public Nginx docroot, not used by Next container) - -## Recommended additions on VPS -- `scripts/redeploy-frontend.sh` (one-command rebuild/verify; executable) -- `tailwind.config.js` -- `postcss.config.js` -- `README.md` (quickstart + CSS playbook) - -## Permissions -- Keep `/root/ddp.env` readable by root only (0600). -- App source owned by deploy user; docker group for Compose runner. - -## Local Developer Environment (Windows) -Local repo root: `G:\Coopeverything\TogetherOS\ddp-on-vps` - -SSH keys: -- Private key: `G:\Coopeverything\TogetherOS\ssh_keys\id_ed25519` -- Public key: `G:\Coopeverything\TogetherOS\ssh_keys\id_ed25519.pub` - -Ensure public key present in: -- `/root/.ssh/authorized_keys` -- `/home/platform/.ssh/authorized_keys` - -System OpenSSH tools (Windows): -- `C:\Windows\System32\OpenSSH\ssh-keygen.exe` -- `C:\Windows\System32\OpenSSH\ssh-keyscan.exe` - -Other local dev assets: -- `G:\Coopeverything\TogetherOS\open-webui\` (local UI experiments; not part of deploy) - -## GitHub Actions Secrets (Deploy Staging) -- SSH_PRIVATE_KEY -- VPS_HOST = continentjump -- VPS_IP = -- VPS_USER = platform -- VPS_PATH = /home/platform/htdocs/platform.local/frontend - diff --git a/docs/index.md b/docs/index.md index 19bc2fee..84dd4541 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,5 @@ # docs/INDEX.md + # TogetherOS — Docs Index (Canon) This page lists the **canonical docs**. If you rename or add a doc, update this index in the same PR. @@ -6,6 +7,7 @@ This page lists the **canonical docs**. If you rename or add a doc, update this --- ## Canon (start here) + - [OPERATIONS.md](./OPERATIONS.md) — contributor playbook (tiny, verifiable steps) - [Manifesto.md](./Manifesto.md) — vision and purpose - [TogetherOS_CATEGORIES_AND_KEYWORDS.md](./TogetherOS_CATEGORIES_AND_KEYWORDS.md) — 8 Paths & keywords @@ -13,15 +15,33 @@ This page lists the **canonical docs**. If you rename or add a doc, update this - [OPS/MAINTAINERS_DEPLOY.md](./OPS/MAINTAINERS_DEPLOY.md) — internal deploy notes (names of secrets only) ## Status & Planning + - Public status explainer: [STATUS_v2.md](./STATUS_v2.md) - Tracker file (append-only log): [STATUS/What_we_finished_What_is_left_v2.txt](../STATUS/What_we_finished_What_is_left_v2.txt) - Tech roadmap: [roadmap/TECH_ROADMAP.md](./roadmap/TECH_ROADMAP.md) +## Knowledge Base (Detailed Specs) + +For comprehensive implementation guides, architecture patterns, and module specifications, see: + +- [Main Knowledge Base](../.claude/knowledge/togetheros-kb.md) — Core identity, workflow, and principles +- [Tech Stack](../.claude/knowledge/tech-stack.md) — Framework versions, dependencies, tooling +- [Architecture Patterns](../.claude/knowledge/architecture.md) — Data models, API contracts, monorepo structure +- [Bridge Module](../.claude/knowledge/bridge-module.md) — Complete AI assistant specification +- [Governance Module](../.claude/knowledge/governance-module.md) — Proposals & decisions implementation +- [Social Economy](../.claude/knowledge/social-economy.md) — Support Points, timebanking, Social Horizon currency +- [Cooperation Paths](../.claude/knowledge/cooperation-paths.md) — Full taxonomy with subcategories +- [CI/CD Discipline](../.claude/knowledge/ci-cd-discipline.md) — Proof lines, validation workflows +- [Data Models](../.claude/knowledge/data-models.md) — Core entities and relationships + ## Contributor Hubs + - Discussions landing: https://github.com/coopeverything/TogetherOS/discussions/88 - Repository README: [../README.md](../README.md) +- Modules Hub: [modules/INDEX.md](./modules/INDEX.md) ## 8 Paths (quick reference) + - Collaborative Education - Social Economy - Common Wellbeing @@ -35,18 +55,21 @@ This page lists the **canonical docs**. If you rename or add a doc, update this > [TogetherOS_CATEGORIES_AND_KEYWORDS.md](./TogetherOS_CATEGORIES_AND_KEYWORDS.md). ## OPS Docs (short & practical) + - OPS ground rules: [OPS/TogetherOS_OPS_Project_Knowledge.md](./OPS/TogetherOS_OPS_Project_Knowledge.md) - CI specifics: [CI/Actions_Playbook.md](./CI/Actions_Playbook.md) - ## How to propose a docs change + 1. One smallest change per PR. 2. Update this index if the change adds/renames/removes a doc. 3. Include proof lines in the PR body: +``` LINT=OK VALIDATORS=GREEN SMOKE=OK +``` 4. If docs-only, ensure `ci/docs` passes (markdown + links). diff --git a/docs/modules/INDEX.md b/docs/modules/INDEX.md index c65d9cb2..2cdb998d 100644 --- a/docs/modules/INDEX.md +++ b/docs/modules/INDEX.md @@ -1,29 +1,48 @@ # TogetherOS — Modules Hub + This hub lists all platform modules with links to specs, active increments, and current progress. + > Update this index whenever a module page is added or renamed. Each module keeps tiny public metrics or proof-lines (e.g., dashboards or `LINT=OK`, `VALIDATORS=GREEN`, `SMOKE=OK`) so progress stays visible across the repo. ## Core Modules + - [Monorepo & Scaffolding](./scaffold.md) - [UI System](./ui.md) - [Identity & Auth](./auth.md) - [Profiles](./profiles.md) - [Groups & Orgs](./groups.md) - [Forum / Deliberation](./forum.md) -- [Proposals & Decisions (Governance)](./governance.md) +- [Proposals & Decisions (Governance)](../../.claude/knowledge/governance-module.md) - [Social Economy Primitives](./social-economy.md) - [Support Points & Reputation](./reputation.md) - [Onboarding](./onboarding.md) - [Bridge — Internal pilot (core team only)](./bridge/landing-pilot.md) + - [Full Bridge Specification](../../.claude/knowledge/bridge-module.md) - [Search & Tags](./search.md) - [Notifications & Inbox](./notifications.md) - [Docs Site Hooks](./docs-hooks.md) - [Observability](./observability.md) - [Security & Privacy](./security.md) +## Knowledge Base (Comprehensive Specs) + +For detailed implementation guides and architecture patterns, see: + +- [Main Knowledge Base](../../.claude/knowledge/togetheros-kb.md) — Core identity and workflow +- [Tech Stack](../../.claude/knowledge/tech-stack.md) — Framework versions, dependencies, tooling +- [Architecture Patterns](../../.claude/knowledge/architecture.md) — Data models, API contracts, monorepo structure +- [Bridge Module](../../.claude/knowledge/bridge-module.md) — Complete AI assistant specification +- [Governance Module](../../.claude/knowledge/governance-module.md) — Proposals & decisions implementation +- [Social Economy](../../.claude/knowledge/social-economy.md) — Support Points, timebanking, Social Horizon currency +- [Cooperation Paths](../../.claude/knowledge/cooperation-paths.md) — Full taxonomy with subcategories +- [CI/CD Discipline](../../.claude/knowledge/ci-cd-discipline.md) — Proof lines, validation workflows +- [Data Models](../../.claude/knowledge/data-models.md) — Core entities and relationships + ## How we build -- **Branches:** `feature/-` from `main` (one tiny change per PR). + +- **Branches:** `feature/` from `main` (one tiny change per PR). - **Issues:** use the **Increment** template; label `module:`, `type:increment`, and `size:S|M|L`. - **Status:** authoritative overview lives in [../STATUS_v2.md](../STATUS_v2.md); each module page shows its own `Progress: X%`. -- **Definition of Done (DoD):** code merged → docs updated (this hub or module page) → proofs in PR body: `LINT=OK` `VALIDATORS=GREEN` `SMOKE=OK`. +- **Definition of Done (DoD):** code merged + docs updated (this hub or module page) + proofs in PR body: `LINT=OK` `VALIDATORS=GREEN` `SMOKE=OK`. diff --git a/docs/modules/bridge.md b/docs/modules/bridge.md deleted file mode 100644 index 3cf89baf..00000000 --- a/docs/modules/bridge.md +++ /dev/null @@ -1,298 +0,0 @@ -# Bridge — AI Assistant Platform - -> An always‑available assistant that helps people understand, deliberate, and act together. Bridge answers questions from our knowledge, tidies threads, and assists moderation with transparent, auditable suggestions — to change how humans decide and behave with one another and the planet. - -**Owner(s):** @coopeverything-core -**Labels:** module:bridge, type:increment, size:XS|S, slice:qna|tidy|ops, target:Now|Next|Later -**Status:** Progress 0% -**Next milestone:** Pilot Bridge-assisted landing page (minimal `/bridge` with streaming Q&A, rate-limit, and NDJSON logs) -**Blockers/Notes:** None - ---- - -> ### Parts (subpages) -> This page is the canonical overview. Detailed work happens in these focused subpages: -> - **docs/modules/bridge/landing-pilot.md** — minimal public `/bridge` page, streaming Q&A, logs & validator **(ready)**. -> - **docs/modules/bridge/faq-seed.md** — curated questions & answers from pilot testers **(coming soon)**. -> - **docs/modules/bridge/api.md** — ask/tidy API contracts, error taxonomy, examples **(coming soon)**. -> - **docs/modules/bridge/ethics-charter.md** — tone, privacy, assist-not-adjudicate guardrails **(coming soon)**. - ---- - -## 1) Why Bridge - -Bridge is a **cooperation amplifier**. It lowers friction to collective intelligence: people can grasp the facts, the trade‑offs, and the next steps without wading through miles of text or adversarial back‑and‑forth. Bridge teaches and reinforces the practices that make cooperation real. - -* **Lower friction for understanding**: converts long docs/threads into concise, cited summaries so members *understand before reacting*. -* **Nudge empathy and deliberation**: encourages steel‑manning, calm language, and trade‑off thinking; suggests reframes when a discussion heats up. -* **Preserve shared memory**: keeps auditable logs of questions, sources, and summaries; makes learning cumulative across the network. -* **Empower local ↔ global**: connects local questions to global knowledge while respecting privacy and consent. -* **Build trust in AI**: every suggestion includes sources, a confidence disclaimer, and appears as assistance — never as adjudication. - -**North‑star outcomes** - -* Faster, calmer decisions with documented trade‑offs. -* More first‑time contributors completing a helpful action in their first session. -* Fewer circular debates; clearer next steps in threads and proposals. - ---- - -## 2) Principles & Guardrails (the Social Contract in code) - -* **Assist, not adjudicate.** Bridge suggests; humans decide. -* **Cite & disclaim.** Every answer shows sources and includes: *“Bridge may be imperfect; verify important details.”* -* **Privacy first.** Index only public repo docs + approved KB exports; redact PII (emails/phones/handles) in outputs and logs. -* **Auditability.** Append‑only logs with IDs, timestamps, content hashes; validation scripts prove integrity. -* **Small, reversible steps.** Deliver tiny increments with clear acceptance and roll‑back paths. - ---- - -## 3) Scope (What Bridge does) - -**Member Q&A / Brainstorm (grounded)** -Answers questions using TogetherOS documentation and approved knowledge exports. Provides citations and simple, respectful prompting for brainstorming. - -**Thread tidy (summaries, tags, actions)** -Summarizes forum topics into a standard structure (problem → options → trade‑offs → open questions → next steps), proposes tags, and extracts candidate actions with links. - -**Moderation assist (suggestions, not decisions)** -Detects heated tone or derailments and suggests de‑escalations, label proposals, or merge/split hints. Includes an appeal link and logs the suggestion with reasons. - -**Onboarding nudge** -“Ask Bridge” is present on first run; suggests two tiny next actions and a person/project to follow, reducing time‑to‑first‑contribution. - -Out of scope (for now): punitive moderation, decision‑making authority, automated enforcement. - ---- - -## 4) Success Metrics (SLOs tied to human behavior) - -* **Time‑to‑first‑useful‑answer (p95)** in fixture mode < 800ms -* **Citation coverage** = 100% for non‑empty answers/summaries -* **Deliberation quality**: % of threads with a tidy card and extracted actions -* **Trust index**: ≥ 70% “helpful” ratings after 30 days -* **Appeals**: median resolution within 7 days; 5xx error budget tracked - ---- - -## 5) Phased Plan (each phase = small, verifiable) - -### Phase 0 — Foundations (Now) - -**Goal:** Ground Bridge in our knowledge and ethics. - -* Curate **Bridge Knowledge Dataset** → `packages/bridge-fixtures/docs.jsonl` (Manifesto, OPS/CI, STATUS, Modules, Social Contract). -* Write the **Bridge Ethics Charter** (tone, fairness, transparency, non‑punitive suggestions). -* Build **dataset curation script** (dedupe, trim, redact PII); add to CI with proof lines. -* Define **citation format** `{ path, lines[] }` + standard disclaimer text. - -**Acceptance:** JSONL exists and passes `scripts/validate.sh`; random samples map to real docs. - ---- - -### Phase 1 — MVP: Q&A + Tidy + Logs (Now) - -**Goal:** Answer a docs question and summarize a thread with sources and logs. - -**API (fixture‑first)** - -* `POST /api/bridge/qa` → `{ "answer": "", "sources": [{"path":"docs/.md","lines":[x,y]}], "disclaimer":"…" }` -* `POST /api/bridge/tidy` → `{ "summary":"", "tags":["type:increment","size:S"], "links":[""], "sources":[…], "disclaimer":"…" }` - -**UI** - -* Persistent **Ask Bridge** input (page‑aware). -* **Tidy with Bridge** button on forum topics → non‑blocking summary card with Copy/Hide/Show + source chips. - -**Logs (append‑only)** - -* `logs/bridge/actions-YYYY-MM-DD.ndjson` entries: `{ id, ts, action: "qa|tidy", inputs, sources, content_hash }` -* `scripts/validate.sh` checks: file exists, last non‑empty line parses as JSON; prints `LINT=OK`, `VALIDATORS=GREEN`, `SMOKE=OK`. - -**Acceptance** - -* Non‑empty outputs include ≥1 valid source from `docs/**` or `STATUS/**`. -* Storybook story renders tidy card; empty/loading/error states covered. - ---- - -### Phase 2 — Deliberation Structure & Empathy (Next) - -**Goal:** Encourage better conversations. - -* Standard **summary structure** enforced in templates (problem → options → trade‑offs → open questions → next steps). -* **Tone cues** (light heuristics): suggest neutral reframes; never punitive. -* **Action extraction**: propose 1–3 next steps + tags (human‑editable). - -**Acceptance:** ≥10 sample threads produce structured summaries; facilitators rate ≥70% “useful”. - ---- - -### Phase 3 — Moderation Assist (Suggestions only) (Later) - -**Goal:** Transparent moderation support with appeal paths. - -* Detect toxicity/derail and **suggest** labels/merges/splits with short rationales and links. -* **Appeal link** on each suggestion; corrections form a learning queue (governed). - -**Acceptance:** Suggestions include source/explanation; appeals logged; no auto‑punitive action. - ---- - -### Phase 4 — Federation & Local Knowledge (Later) - -**Goal:** Help local groups while sharing learning globally. - -* **Per‑group indices** (workspace scoping) with opt‑in export. -* Global **insight cards** (anonymized patterns) curated by humans. - -**Acceptance:** Local index enabled; global feed shows anonymized insights with curator sign‑off. - ---- - -### Phase 5 — Continuous Learning & Audits (Later) - -**Goal:** Community‑governed improvement. - -* Feedback tagging (helpful/bias/off‑topic) and weekly review. -* Monthly **audit MD** (what Bridge suggested, where it erred, how it changed). - -**Acceptance:** Monthly audit published; trending issues down over time. - ---- - -## 6) Architecture (minimal, auditable) - -* **Interfaces** → `apps/frontend/app/(modules)/bridge/*` - `/bridge` explainer page; Ask input; Tidy button + card; Storybook stories. -* **Domain** → `packages/bridge-domain/*` - Entities: `BridgeQuery`, `BridgeAnswer`, `BridgeSummary`. -* **API** → `packages/bridge-api/*` - Handlers: `/api/bridge/qa`, `/api/bridge/tidy` (fixture‑first, schema‑checked). -* **Fixtures** → `packages/bridge-fixtures/*` - `docs.jsonl` + tiny keyword index (deterministic search for MVP). -* **Logs** → `logs/bridge/` - Append‑only NDJSON; daily rotation; `.gitkeep` tracked. - -**Config**: `BRIDGE_ENABLED`, `BRIDGE_TIDY_ENABLED`, `BRIDGE_FIXTURES`, `BRIDGE_LOG_DIR`, `BRIDGE_LOG_KEY`. - ---- - -## 7) Data, Privacy & Ethics - -* Index only **public repo docs + approved KB exports**; exclude private messages by default. -* Redact PII in summaries and logs; store **paths + line ranges**, not bodies. -* Every output shows disclaimer + source chips; members can click **Challenge/Correct**. - ---- - -## 8) Training & Setup (clear actions) - -1. **Assemble dataset** → export Manifesto, OPS/CI, STATUS, Modules into `docs.jsonl` with `{title, path, text}`. -2. **Curation script** → dedupe, trim, and PII redaction (emails/phones/handles/URLs). -3. **Fixture‑first retrieval** → keyword index over JSONL; normalize citations to `{ path, lines[] }`. -4. **Summarizer** → deterministic template‑based summarizer; optional local LLM (Ollama) later behind a flag. -5. **Tone cues** → minimal heuristic rules (e.g., 2nd‑person accusations, all‑caps spikes) with suggested reframes. -6. **Logging** → NDJSON append with `id, ts, inputs, sources, content_hash`; integrity check in `scripts/validate.sh`. -7. **Governance loop** → Bridge Oversight Circle; appeal labels; weekly triage. - ---- - -## 9) API Contracts (MVP) - -**POST /api/bridge/qa** - -```json -Req: { "question": "How do I run smoke?" } -Res: { - "answer": "Run `scripts/smoke.sh` …", - "sources": [{ "path": "docs/CI/Actions_Playbook.md", "lines": [42, 60] }], - "disclaimer": "Bridge may be imperfect; verify important details." -} -Errors: 204 (empty), 401, 403, 422 (no sources), 500 -``` - -**POST /api/bridge/tidy** - -```json -Req: { "threadId": "abc123" } -Res: { - "summary": "- What’s proposed…\n- Open questions…", - "tags": ["type:increment","size:S"], - "links": ["https://…/thread/abc123"], - "sources": [{ "path": "STATUS/What_we_finished_What_is_left.md", "lines": [12, 28] }], - "disclaimer": "Bridge may be imperfect; verify important details." -} -``` - -**Schemas & errors** should be validated (Zod or equivalent) with a standard taxonomy: `401` unauth, `403` disabled/flag off, `422` contract breach (e.g., missing sources), `204` empty input, `500` unexpected. - ---- - -## 10) CI Hooks & Proof Lines - -* `scripts/validate.sh` must output exactly: - -``` -LINT=OK -VALIDATORS=GREEN -SMOKE=OK -``` - -* Checks: JSONL fixture integrity, last log line parses as JSON, API example schemas pass, Storybook builds. - ---- - -## 11) Smaller Projects (to split into docs & issues) - -Break this module into contributor‑friendly projects: - -1. **Bridge Knowledge Dataset** - *Docs export + curation script + JSONL fixtures* - -2. **Bridge Ethics Charter** - *Codifies tone, fairness, transparency; links to Social Contract; informs prompts and UI* - -3. **Q&A Endpoint (Fixture‑First)** - *`/api/bridge/qa` + schemas + tests + citations* - -4. **Thread‑Tidy Endpoint (Fixture‑First)** - *`/api/bridge/tidy` + structure template + tests + tags* - -5. **Ask Bridge UI** - *Global input, loading/empty/error states, source chips, a11y* - -6. **Tidy Card UI** - *Summary card with Copy/Hide/Show; Storybook; keyboard/focus order* - -7. **Append‑only Logs** - *NDJSON writer + daily rotation + integrity validator* - -8. **Tone Cues & Reframes (Heuristics)** - *Non‑punitive prompts that suggest listening and trade‑offs* - -9. **Appeals & Feedback Loop** - *Challenge/Correct UI; oversight cadence; monthly audit template* - -10. **Federated Indices (Local ↔ Global)** - *Workspace scoping; anonymized insight cards pipeline* - -Each project should include: **scope, acceptance, labels (module:bridge, slice:*, size:XS|S, target:Now|Next|Later)** and a tiny proof (contract test or script output). - ---- - -## 12) Pilot: Public Landing Page (Owner-led) - -We’ll first ship a minimal /bridge page so visitors can ask “What is TogetherOS?” and get a calm, mission-first, streamed answer. This pilot uses a hosted LLM via API (no tools yet), logs anonymized requests, and seeds the Bridge FAQ we’ll curate from trusted testers. Contributors can help with the streaming UI, the /api/bridge/ask endpoint (rate-limit + error taxonomy + NDJSON logs), Storybook states, and a CI validator that prints LINT=OK / VALIDATORS=GREEN / SMOKE=OK. -For full scope, acceptance, and owner guidance (including how I’ll work with the assistant to draft prompts, simulate testers, and curate the FAQ), see docs/modules/bridge/landing-pilot.md. - ---- - -## 13) Link Hygiene - -* Keep this doc referenced from the **Modules Hub** and from the **Manifesto CTA** for contributors: “Find the whole list of modules here.” -* When renaming/moving files, list likely inbound links and provide a safe find/replace command in the PR description. - ---- - -**When code starts:** open branch `feature/bridge-qna-tidy-mvp` and implement Phase 1. diff --git a/docs/modules/governance.md b/docs/modules/governance.md deleted file mode 100644 index 204c2eee..00000000 --- a/docs/modules/governance.md +++ /dev/null @@ -1,55 +0,0 @@ -# Proposals & Decisions (Governance) - -**Scope:** Create, deliberate, and decide on proposals with transparent rules and lightweight, testable flows. -**Owner(s):** @coopeverything-core -**Labels:** `module:governance` - -## Status -Progress: 0% -Next milestone: Submit a minimal proposal and see it in a list. -Blockers/Notes: none - -## Why this exists -Members must be able to turn ideas into proposals, discuss them, and make decisions. We ship a thin vertical slice first so contributors can see end-to-end value quickly: submit → list → view details (voting later). - -## MVP slices (order) -1. **Proposal create (API + domain)** - - **acceptance:** - - `POST /api/proposals` validates with Zod (`title`, `summary`, `authorId`, `createdAt`). - - Stores to in-memory/fixture repo; returns `201` with `{id}`. - - Unit test covers happy path + validation errors. -2. **Proposal list (UI)** - - **acceptance:** - - Route `/governance` lists `title`, `author`, `createdAt`. - - Empty state, loading skeleton, and generic error are present. - - Storybook story for `` with empty/loaded states. -3. **Proposal details (UI + API)** - - **acceptance:** - - `/governance/[id]` shows `title`, `summary`, timestamps. - - 404 guarded (invalid id). - - Contract test for `GET /api/proposals/:id` with Zod parsing. -4. **Seed & fixtures (ops)** - - **acceptance:** - - `packages/governance-fixtures/seed.ts` adds 3 demo proposals. - - `pnpm -w seed:governance` runs and logs inserted ids. - - Proof-line in `scripts/validate.sh` confirms seeds runnable. - -## Code map -- `apps/frontend/app/(modules)/governance/*` (routes, server actions, tests) -- `packages/governance-domain/*` (entities, repo interfaces, unit tests) -- `packages/governance-api/*` (REST handlers, Zod contracts) -- `packages/governance-ui/*` (components, Storybook stories) -- `packages/governance-fixtures/*` (seed data, demo JSON) - -## UI contract (brief) -- `/governance` → `` (state: `proposals[]`) -- `/governance/[id]` → `` (state: `proposal | 404`) -- States required on both pages: **empty**, **loading**, **error**. - -## Done → Tell the story (DoD) -- Tests or manual steps verified (list loads, details render, create works). -- Docs updated (this page + link in `docs/modules/INDEX.md` already present). -- Proofs in PR body: -LINT=OK -VALIDATORS=GREEN -SMOKE=OK diff --git a/docs/modules/rewards.md b/docs/modules/rewards.md new file mode 100644 index 00000000..0551e329 --- /dev/null +++ b/docs/modules/rewards.md @@ -0,0 +1,857 @@ +# Rewards Module — Recognition & Reputation + +## Overview + +**Rewards** is TogetherOS's system for recording meaningful contributions and converting participation into visible recognition through Support Points, badges, and cooperative currency flows. + +**Status:** 0% implementation (🎯 **First contributor module**) +**Owner:** @coopeverything-core +**Labels:** `module:rewards`, `good-first-issue` +**Priority:** Foundation for community engagement + +--- + +## Why Rewards Exists + +### The Problem +- Contributions go unrecognized → people disengage +- No visible path from participation to impact +- Trust and reputation built on word-of-mouth only +- Early contributors deserve credit for building foundation + +### The Solution +Rewards **makes cooperation visible and rewarding**: +- Every action (code, docs, governance, care) generates verifiable events +- Support Points quantify contribution across all domains +- Badges tell the story of what each person has done +- Recognition naturally enhances trust and opportunity + +### North-Star Outcomes +- Early code/infrastructure contributors get lasting recognition +- First-time contributors see clear progression paths +- Reputation becomes portable proof of cooperative skill +- Recognition system scales to all 8 Cooperation Paths + +--- + +## Core Principles + +1. **Recognition is nourishment** — Humans thrive when peers acknowledge contributions +2. **Transparency and fairness** — Every reward derives from recorded, verifiable action +3. **Scalable cooperation** — Same logic applies across all participation domains +4. **Proof of cooperation** — Actions, not titles/possessions, define contribution + +--- + +## Domains of Contribution + +All contribution domains will eventually generate reward events: + +| Domain | Examples | +|--------|----------| +| **Technology & Infrastructure** 🎯 | Code, docs, automation, systems design, maintenance | +| **Governance & Civic Life** | Facilitating deliberations, drafting proposals, mediation | +| **Education & Mentorship** | Teaching, translating, mentoring, documenting | +| **Social Economy** | Launching co-ops, mutual aid, timebanking | +| **Community Care** | Emotional support, accessibility, crisis management | +| **Culture & Media** | Films, music, writing, art uplifting cooperation | +| **Environment & Planet** | Regenerative projects, ecological restoration | +| **Design & UX** | Usability, accessibility, aesthetics improvements | + +**🎯 Phase A Focus:** Technology & Infrastructure (code contributors first) + +--- + +## Reward Mechanics + +### 1. Support Points (SP) +**Purpose:** Quantitative acknowledgment of contribution + +**How They Work:** +- Earned through verified actions (PR merges, proposals, facilitation) +- Increase visibility and unlock privileges +- Enable participation in collective decisions +- Partially convertible to timebank credits or Social Horizon + +**Example Weights (Phase A):** +```typescript +const SP_WEIGHTS = { + pr_merged_small: 5, // < 50 lines changed + pr_merged_medium: 10, // 50-200 lines + pr_merged_large: 20, // > 200 lines + docs_contribution: 8, // Documentation PR + code_review: 3, // Helpful review feedback + issue_triage: 2, // Issue labeling/clarification + bug_fix: 15, // Critical bug resolution +} +``` + +### 2. Badges & Skill Trees +**Purpose:** Represent milestones in contribution or mastery + +**Badge Examples:** +- 🔧 **First PR** — Merged your first contribution +- 🏗️ **Foundation Builder** — 10+ PRs in pre-MVP phase +- 📚 **Documentation Champion** — 5+ doc improvements +- 🐛 **Bug Hunter** — Fixed 5+ critical bugs +- 🎨 **UI Craftsperson** — 3+ UI/UX improvements +- 🔍 **Code Reviewer** — 10+ helpful reviews +- 🚀 **Module Launcher** — Shipped a complete module + +**Visibility:** Publicly displayed on profiles, portable across projects + +### 3. Timebank Credits (Future) +- Barter units exchangeable for help/mentorship within network +- Support Points partially convertible (e.g., 100 SP → 1 hour credit) + +### 4. Social Horizon Fractions (Future) +- Cooperative currency for lasting value creation +- Distributed with anti-speculation safeguards +- Tied to verified community benefit + +--- + +## Implementation Sequence + +### Phase A: Foundation (Now) 🎯 +**Goal:** Store early contributor actions (coders, designers) + +**Deliverables:** +- [ ] Event ledger schema + NDJSON storage +- [ ] GitHub webhook integration (PR events) +- [ ] Event collector API endpoint +- [ ] Basic event validation + deduplication +- [ ] Fixture data for testing + +**Outcome:** PR merges automatically recorded in event ledger + +--- + +### Phase B: Reward Logic (Next) +**Goal:** Calculate points and award badges + +**Deliverables:** +- [ ] Reward engine (event → SP calculation) +- [ ] Badge ruleset (YAML config) +- [ ] Member profile API (balances, badges, history) +- [ ] Anti-gaming safeguards (cooldowns, diversity checks) + +**Outcome:** Members see earned Support Points and badges + +--- + +### Phase C: Community Integration (Later) +**Goal:** Expand to all participation domains + +**Deliverables:** +- [ ] Bridge integration (Q&A, tidy contributions) +- [ ] Forum integration (facilitation, quality posts) +- [ ] Governance integration (proposals, votes, facilitation) + +**Outcome:** All 8 Cooperation Paths generating rewards + +--- + +### Phase D: Exchange Layer (Later) +**Goal:** Points convertible into cooperative currency + +**Deliverables:** +- [ ] Timebank integration +- [ ] Social Horizon integration +- [ ] Conversion rules and limits + +**Outcome:** SP → timebank credits → real cooperative value + +--- + +### Phase E: Analytics & Growth (Later) +**Goal:** Transparent, motivational data loops + +**Deliverables:** +- [ ] Public leaderboards (opt-in) +- [ ] Contribution reports +- [ ] Analytics dashboard + +**Outcome:** Community sees cooperative progress + +--- + +## Data Models + +### Event (Core Entity) +```typescript +interface RewardEvent { + id: string // UUID + actor_id: string // Member who performed action + event_type: RewardEventType + timestamp: Date + context: EventContext // Domain-specific metadata + source: string // Origin (github, forum, bridge) + weight: number // SP value (calculated) + status: 'pending' | 'processed' | 'rejected' + processed_at?: Date +} + +type RewardEventType = + // Code & Infrastructure + | 'pr_merged' + | 'pr_reviewed' + | 'issue_created' + | 'issue_triaged' + | 'bug_fixed' + | 'docs_contribution' + // Governance (future) + | 'proposal_submitted' + | 'proposal_facilitated' + | 'vote_cast' + // Community (future) + | 'moderation_action' + | 'community_event_hosted' + | 'bridge_qa_helpful' + | 'thread_tidied' + +interface EventContext { + // GitHub events + pr_number?: number + pr_size?: 'small' | 'medium' | 'large' + files_changed?: number + lines_changed?: number + repository?: string + + // Forum events (future) + thread_id?: string + post_quality?: number + + // Governance events (future) + proposal_id?: string + decision_id?: string + + // Generic + [key: string]: any +} +``` + +### Member Balance +```typescript +interface MemberRewardBalance { + member_id: string + support_points_total: number // All-time earned + support_points_available: number // Current balance + support_points_allocated: number // Locked in proposals + badges: Badge[] + level: number // Derived from total SP + created_at: Date + updated_at: Date +} +``` + +### Badge +```typescript +interface Badge { + id: string // UUID + name: string // Display name + description: string + icon: string // Emoji or URL + criteria: BadgeCriteria + rarity: 'common' | 'uncommon' | 'rare' | 'legendary' + earned_at?: Date +} + +interface BadgeCriteria { + event_types: RewardEventType[] + threshold: number // How many events required + conditions?: Record +} +``` + +### Transaction Log +```typescript +interface RewardTransaction { + id: string // UUID + member_id: string + type: 'earn' | 'allocate' | 'reclaim' | 'convert' + amount: number + source_event_id?: string // Link to RewardEvent + target_id?: string // Proposal/initiative receiving allocation + timestamp: Date + metadata: Record +} +``` + +--- + +## API Contracts + +### POST /api/rewards/events + +**Purpose:** Receive contribution events from external systems + +**Request:** +```typescript +{ + actor_id: string // Member UUID + event_type: string // From RewardEventType + source: string // 'github' | 'forum' | 'bridge' + context: { + // Event-specific data + pr_number?: number + files_changed?: number + // ... + } +} +``` + +**Response (Success):** +```typescript +201 Created +{ + id: string // Event UUID + weight: number // Calculated SP + processed: boolean +} +``` + +**Response (Duplicate):** +```typescript +409 Conflict +{ + error: { + code: "EVENT_ALREADY_PROCESSED", + message: "Event with this source and context already exists" + } +} +``` + +--- + +### GET /api/rewards/members/:id/balance + +**Purpose:** Retrieve member's reward balance and badges + +**Response:** +```typescript +200 OK +{ + member_id: string + support_points: { + total: number + available: number + allocated: number + } + badges: Badge[] + level: number + rank_percentile?: number // Optional: where they stand + recent_events: RewardEvent[] // Last 10 +} +``` + +--- + +### GET /api/rewards/leaderboard + +**Purpose:** Public leaderboard (opt-in members only) + +**Query Params:** +```typescript +{ + period?: 'week' | 'month' | 'all-time' + domain?: CooperationPath // Filter by contribution domain + limit?: number // Default 50, max 100 +} +``` + +**Response:** +```typescript +200 OK +{ + period: string + updated_at: Date + leaders: Array<{ + member_id: string + handle: string // Public identifier + support_points: number + badges_count: number + rank: number + }> +} +``` + +--- + +## GitHub Integration (Phase A) + +### Webhook Configuration + +**Events to Listen:** +- `pull_request` (opened, closed, merged) +- `pull_request_review` (submitted) +- `issues` (opened, labeled) +- `push` (to main/release branches) + +**Webhook Handler Flow:** +```typescript +async function handleGitHubWebhook(payload: WebhookPayload) { + // 1. Verify signature + if (!verifyGitHubSignature(payload)) { + return 401 + } + + // 2. Extract event data + const event = extractRewardEvent(payload) + + // 3. Map GitHub user to TogetherOS member + const member = await mapGitHubToMember(payload.sender) + + // 4. Create reward event + const rewardEvent = await createRewardEvent({ + actor_id: member.id, + event_type: determineEventType(payload), + source: 'github', + context: extractContext(payload) + }) + + // 5. Process immediately or queue + await processRewardEvent(rewardEvent) + + return 200 +} +``` + +### Event Mapping Examples + +**PR Merged:** +```typescript +{ + event_type: 'pr_merged', + context: { + pr_number: 42, + pr_size: 'medium', // Based on files/lines changed + files_changed: 8, + lines_changed: 156, + repository: 'coopeverything/TogetherOS' + } +} +``` + +**Code Review:** +```typescript +{ + event_type: 'pr_reviewed', + context: { + pr_number: 42, + review_quality: 'helpful', // Determined by PR author reaction + comments_count: 3 + } +} +``` + +--- + +## Event Storage (NDJSON) + +### Log Format +```typescript +// logs/rewards/events-YYYY-MM-DD.ndjson +{ + "id": "uuid", + "timestamp": "2025-01-15T10:30:00Z", + "event_type": "pr_merged", + "actor_id": "member-uuid", + "source": "github", + "context": { + "pr_number": 42, + "pr_size": "medium", + "files_changed": 8, + "lines_changed": 156 + }, + "weight": 10, + "status": "processed", + "content_hash": "sha256..." +} +``` + +### Validation Rules +- File must be valid NDJSON (each line = JSON object) +- Required fields: `id`, `timestamp`, `event_type`, `actor_id` +- Integrity: SHA-256 chain validation +- Rotation: Daily log files + +### CI Validation +```bash +# scripts/validate.sh checks: +# - NDJSON format valid +# - Last line parses successfully +# - Required fields present +# - No duplicate event IDs + +# Expected output: +LINT=OK +VALIDATORS=GREEN +SMOKE=OK +``` + +--- + +## Anti-Gaming Safeguards + +### Cooldowns +- Same event type: 1 hour minimum between similar actions +- PR spam: Max 5 PRs per day counted +- Review spam: Max 10 reviews per day + +### Diversity Checks +- Bonus for contributing across multiple domains +- Penalty for only single-type contributions + +### Multi-Review Validation +- Large SP awards (>50) require admin approval +- Suspicious patterns flagged for review +- Appeal process for rejected events + +### Public Audit +- All rules and weights published in versioned docs +- Monthly review of reward distribution +- Community proposals can adjust weights + +--- + +## Badge Ruleset (YAML Config) + +### Example Configuration +```yaml +badges: + - id: first-pr + name: First PR + description: Merged your first pull request + icon: 🔧 + rarity: common + criteria: + event_types: [pr_merged] + threshold: 1 + + - id: foundation-builder + name: Foundation Builder + description: Merged 10+ PRs in pre-MVP phase + icon: 🏗️ + rarity: uncommon + criteria: + event_types: [pr_merged] + threshold: 10 + conditions: + phase: pre-mvp + + - id: bug-hunter + name: Bug Hunter + description: Fixed 5+ critical bugs + icon: 🐛 + rarity: rare + criteria: + event_types: [bug_fixed] + threshold: 5 + conditions: + severity: critical + + - id: module-launcher + name: Module Launcher + description: Shipped a complete module to production + icon: 🚀 + rarity: legendary + criteria: + event_types: [pr_merged] + threshold: 1 + conditions: + module_complete: true +``` + +--- + +## UI Components (Phase B) + +### RewardBalance Widget +```typescript +interface RewardBalanceProps { + memberId: string +} + +// Display on member profile sidebar +// Shows: SP total, available, allocated +// Visual: Progress bar toward next level +``` + +### BadgeCollection +```typescript +interface BadgeCollectionProps { + badges: Badge[] + layout: 'grid' | 'list' +} + +// Display earned badges with tooltips +// Click to see criteria and progress +``` + +### RewardHistory +```typescript +interface RewardHistoryProps { + memberId: string + limit?: number +} + +// Timeline of reward events +// Filter by event type, domain +// Export capability +``` + +### Leaderboard +```typescript +interface LeaderboardProps { + period: 'week' | 'month' | 'all-time' + domain?: CooperationPath +} + +// Opt-in only (privacy default) +// Anonymized handles for non-opted-in +// Motivational, not competitive +``` + +--- + +## Repository Pattern + +### Interface +```typescript +// packages/rewards-domain/repos/RewardEventRepo.ts +export interface RewardEventRepo { + create(event: CreateRewardEventInput): Promise + findById(id: string): Promise + findByMember(memberId: string, filters?: EventFilters): Promise + findPending(): Promise + markProcessed(id: string): Promise + checkDuplicate(source: string, context: EventContext): Promise +} + +export interface EventFilters { + event_types?: RewardEventType[] + date_range?: { start: Date; end: Date } + status?: 'pending' | 'processed' | 'rejected' + limit?: number +} +``` + +### In-Memory Implementation (MVP) +```typescript +// packages/rewards-domain/repos/InMemoryRewardEventRepo.ts +export class InMemoryRewardEventRepo implements RewardEventRepo { + private events: Map = new Map() + + async create(input: CreateRewardEventInput): Promise { + const event: RewardEvent = { + id: generateId(), + actor_id: input.actor_id, + event_type: input.event_type, + timestamp: new Date(), + context: input.context, + source: input.source, + weight: calculateWeight(input.event_type, input.context), + status: 'pending', + } + this.events.set(event.id, event) + return event + } + + async findByMember(memberId: string, filters?: EventFilters): Promise { + let results = Array.from(this.events.values()) + .filter(e => e.actor_id === memberId) + + if (filters?.event_types) { + results = results.filter(e => filters.event_types!.includes(e.event_type)) + } + + if (filters?.date_range) { + results = results.filter(e => + e.timestamp >= filters.date_range!.start && + e.timestamp <= filters.date_range!.end + ) + } + + return results.slice(0, filters?.limit || 100) + } + + // ... other methods +} +``` + +--- + +## Testing Strategy + +### Unit Tests +```typescript +// packages/rewards-domain/__tests__/RewardEvent.test.ts +describe('RewardEvent', () => { + it('calculates weight for small PR', () => { + const event = createPREvent({ size: 'small' }) + expect(event.weight).toBe(5) + }) + + it('prevents duplicate events', async () => { + await repo.create({ source: 'github', context: { pr_number: 42 } }) + const isDupe = await repo.checkDuplicate('github', { pr_number: 42 }) + expect(isDupe).toBe(true) + }) +}) +``` + +### Contract Tests +```typescript +// apps/api/src/modules/rewards/__tests__/createEvent.test.ts +describe('POST /api/rewards/events', () => { + it('accepts valid event', async () => { + const response = await request(app) + .post('/api/rewards/events') + .send({ + actor_id: 'member-123', + event_type: 'pr_merged', + source: 'github', + context: { pr_number: 42 } + }) + + expect(response.status).toBe(201) + expect(response.body).toHaveProperty('id') + }) + + it('rejects duplicate event', async () => { + // Create first time + await createEvent({ pr_number: 42 }) + + // Try duplicate + const response = await request(app) + .post('/api/rewards/events') + .send({ pr_number: 42 }) + + expect(response.status).toBe(409) + }) +}) +``` + +### Integration Tests +```typescript +// Full flow: GitHub webhook → Event → SP calculation → Badge award +describe('GitHub webhook integration', () => { + it('awards badge for first PR merge', async () => { + const payload = createPRMergedPayload({ user: 'alice' }) + await handleWebhook(payload) + + const member = await getMember('alice') + expect(member.badges).toContainBadge('first-pr') + expect(member.support_points_total).toBe(10) + }) +}) +``` + +--- + +## Governance & Ethics + +### Transparency Commitments +- **Public weights:** All SP calculations documented +- **Versioned rules:** Changes tracked in git with rationale +- **Monthly audits:** Distribution reports published +- **Community proposals:** Weight adjustments via governance + +### Consent & Privacy +- **Opt-in leaderboards:** Public display requires consent +- **Anonymized aggregates:** Community stats don't expose individuals +- **Export capability:** Members download their complete history +- **Deletion rights:** Remove from public view anytime + +### Fairness Principles +- **Quality over quantity:** Emphasize meaningful contributions +- **Collaboration bonuses:** Reward helping others succeed +- **Empathy weight:** Facilitation and care work valued equally +- **Anti-whale:** Prevent gaming through cooldowns and review + +--- + +## Definition of Done (Phase A) + +When Phase A is complete: + +✅ Event ledger schema defined and documented +✅ NDJSON storage with daily rotation working +✅ GitHub webhook handler receives PR events +✅ Events mapped to reward events correctly +✅ Deduplication prevents double-counting +✅ Fixture data seeds test events +✅ Validation script checks NDJSON integrity +✅ Unit tests pass for event creation +✅ Contract tests pass for API endpoints +✅ Proof lines in CI: `LINT=OK`, `VALIDATORS=GREEN`, `SMOKE=OK` + +--- + +## Contributing (For Developers) + +### 🎯 This is the FIRST module open to contributors! + +We've designed the Reward System to be accessible with clear, small issues perfect for first-time TogetherOS contributors. + +**Why Start Here:** +- Foundation for all future contribution tracking +- Well-scoped tasks with clear acceptance criteria +- Your work directly benefits YOU (you'll earn the first badges!) +- Pattern you establish will be used across all modules + +### Getting Started + +1. **Read this spec** — Understand the full picture +2. **Check Issues** — Look for `good-first-issue` labels +3. **Join Discussions** — Ask questions in Discussions #88 +4. **Small PRs** — One change per PR, well-tested +5. **Follow workflow** — Branch from `Claude-1st-build`, target back to it + +### Issue Breakdown Strategy + +We've broken Phase A into ~15 small issues, each completable in 2-4 hours: + +**Category: Schema & Models** +- Define RewardEvent entity +- Define MemberRewardBalance entity +- Define Badge entity +- Create Zod validation schemas + +**Category: Repository Layer** +- Create RewardEventRepo interface +- Implement InMemoryRewardEventRepo +- Add deduplication logic +- Create fixture seed data + +**Category: API Layer** +- Create POST /api/rewards/events endpoint +- Create GET /api/rewards/members/:id/balance endpoint +- Add event validation middleware +- Error handling and status codes + +**Category: GitHub Integration** +- Setup webhook receiver +- Map PR events to RewardEvent +- Calculate SP weights +- Handle edge cases (bot PRs, etc.) + +**Category: Infrastructure** +- NDJSON log writer +- Log rotation (daily) +- Validation script +- CI integration + +See detailed issues in GitHub with `module:rewards` label. + +--- + +## Related KB Files + +- [Main KB](./togetheros-kb.md) — Core principles, workflow +- [Architecture](./architecture.md) — Domain-driven design, repository pattern +- [Data Models](./data-models.md) — Complete entity specifications +- [Social Economy](./social-economy.md) — Support Points allocation, timebanking +- [Cooperation Paths](./cooperation-paths.md) — All 8 domains that generate rewards +- [CI/CD Discipline](./ci-cd-discipline.md) — Proof lines, validation workflows diff --git a/docs/project_structure.md b/docs/project_structure.md deleted file mode 100644 index 678201dc..00000000 --- a/docs/project_structure.md +++ /dev/null @@ -1,43 +0,0 @@ -# Project Structure - -## Repo layout (recommended) -``` -. -├── apps/ -│ └── frontend/ (Next.js 14 + Tailwind v4) -│ ├── docker-compose.yml -│ ├── Dockerfile -│ ├── src/ -│ │ ├── pages/ -│ │ │ ├── _app.tsx -│ │ │ └── signup.tsx -│ │ └── styles/globals.css -│ ├── tailwind.config.js -│ ├── postcss.config.js -│ └── public/ -├── packages/ -│ └── ui/ (future shared components, tokens) -├── scripts/ -│ └── smoke.sh (basic repo checks; runs in CI) -├── .github/ -│ └── workflows/ -│ ├── ci.yml (build/lint/test/smoke; skips install if no package.json) -│ └── deploy.yml (rsync + VPS redeploy on PR label 'staging-ok') -└── docs/ - ├── DDP_Knowledge.md - ├── DDP_Tech_Roadmap.md - └── OPERATIONS.md -``` - -## CSS update playbook (short version) -1. Edit `src/styles/globals.css` (scoped `.signup` rules). -2. Run: `docker compose build --no-cache && docker compose up -d` -3. Verify: - ```bash - curl -s "http://127.0.0.1:3010/signup?nocache=$(date +%s)" | grep -n ' + findById(id: string): Promise + listByPath(path: string): Promise + award(badgeId: string, memberId: string): Promise + getMemberBadges(memberId: string): Promise +} + +// apps/api/src/modules/rewards/repos/InMemoryBadgeRepo.ts +export class InMemoryBadgeRepo implements BadgeRepo { + private badges = new Map() + private awards = new Map() // memberId -> badgeIds + + async create(input: CreateBadgeInput): Promise { + const badge = Badge.create(input) + this.badges.set(badge.id, badge) + return badge + } + + async award(badgeId: string, memberId: string): Promise { + const badge = await this.findById(badgeId) + if (!badge) throw new Error('Badge not found') + + const memberBadges = this.awards.get(memberId) || [] + if (!memberBadges.includes(badgeId)) { + memberBadges.push(badgeId) + this.awards.set(memberId, memberBadges) + } + } + + async getMemberBadges(memberId: string): Promise { + const badgeIds = this.awards.get(memberId) || [] + return Promise.all( + badgeIds.map(id => this.findById(id)).filter(b => b !== null) + ) + } +} +``` + +### 4. Create Zod Schemas + +```typescript +// packages/validators/src/rewards.ts +import { z } from 'zod' + +export const createBadgeSchema = z.object({ + name: z.string().min(3).max(50), + description: z.string().min(10).max(200), + icon: z.string().emoji().or(z.string().url()), + path: z.enum(['builder', 'community_heart', 'guided_contributor', 'steady_cultivator']), + criteria: z.object({ + type: z.enum(['pr_count', 'mutual_aid_count', 'event_organized', 'custom']), + threshold: z.number().positive(), + timeframe: z.enum(['all_time', 'monthly', 'yearly']).optional(), + }), +}) + +export const awardBadgeSchema = z.object({ + badgeId: z.string().uuid(), + memberId: z.string().uuid(), + reason: z.string().min(10).max(200), +}) +``` + +### 5. Implement API Handler + +```typescript +// apps/api/src/modules/rewards/handlers/awardBadge.ts +import { awardBadgeSchema } from '@togetheros/validators' +import { BadgeRepo } from '../repos' + +export async function awardBadge( + input: unknown, + repo: BadgeRepo +) { + const data = awardBadgeSchema.parse(input) + + // Check if badge exists + const badge = await repo.findById(data.badgeId) + if (!badge) { + return { error: { code: 'BADGE_NOT_FOUND', message: 'Badge does not exist' } } + } + + // Award badge + await repo.award(data.badgeId, data.memberId) + + // Log transaction (append-only NDJSON) + await logRewardTransaction({ + type: 'badge_awarded', + badgeId: data.badgeId, + memberId: data.memberId, + reason: data.reason, + timestamp: new Date().toISOString(), + }) + + return { success: true } +} +``` + +### 6. Build UI Component + +```typescript +// packages/ui/src/rewards/BadgeCard.tsx +import { Badge } from '@togetheros/types' + +interface BadgeCardProps { + badge: Badge + earnedAt?: Date + locked?: boolean +} + +export function BadgeCard({ badge, earnedAt, locked = false }: BadgeCardProps) { + return ( +
+ {/* Icon */} +
{badge.icon}
+ + {/* Name */} +

{badge.name}

+ + {/* Description */} +

{badge.description}

+ + {/* Earned date */} + {earnedAt && ( +

+ Earned {earnedAt.toLocaleDateString()} +

+ )} + + {/* Locked overlay */} + {locked && ( +
+ 🔒 +
+ )} +
+ ) +} +``` + +### 7. Write Tests + +```typescript +// apps/api/src/modules/rewards/__tests__/awardBadge.test.ts +import { describe, it, expect, beforeEach } from 'vitest' +import { InMemoryBadgeRepo } from '../repos/InMemoryBadgeRepo' +import { awardBadge } from '../handlers/awardBadge' + +describe('awardBadge', () => { + let repo: InMemoryBadgeRepo + + beforeEach(() => { + repo = new InMemoryBadgeRepo() + }) + + it('awards badge to member', async () => { + // Setup + const badge = await repo.create({ + name: 'First PR', + description: 'Merged your first PR', + icon: '🎉', + path: 'builder', + criteria: { type: 'pr_count', threshold: 1 }, + }) + + // Execute + const result = await awardBadge({ + badgeId: badge.id, + memberId: 'member-123', + reason: 'PR #42 merged', + }, repo) + + // Assert + expect(result.success).toBe(true) + + const memberBadges = await repo.getMemberBadges('member-123') + expect(memberBadges).toHaveLength(1) + expect(memberBadges[0].id).toBe(badge.id) + }) + + it('returns error for non-existent badge', async () => { + const result = await awardBadge({ + badgeId: 'fake-id', + memberId: 'member-123', + reason: 'Test', + }, repo) + + expect(result.error?.code).toBe('BADGE_NOT_FOUND') + }) + + it('prevents duplicate badge awards', async () => { + const badge = await repo.create({ + name: 'First PR', + description: 'Merged your first PR', + icon: '🎉', + path: 'builder', + criteria: { type: 'pr_count', threshold: 1 }, + }) + + // Award twice + await awardBadge({ badgeId: badge.id, memberId: 'member-123', reason: 'First' }, repo) + await awardBadge({ badgeId: badge.id, memberId: 'member-123', reason: 'Second' }, repo) + + // Should only have one + const memberBadges = await repo.getMemberBadges('member-123') + expect(memberBadges).toHaveLength(1) + }) +}) +``` + +### 8. Create Fixtures + +```typescript +// packages/fixtures/src/badges.ts +export const badgeFixtures = [ + { + id: 'badge-first-pr', + name: 'First PR Merged', + description: 'Congratulations on your first merged pull request!', + icon: '🎉', + path: 'builder' as const, + criteria: { type: 'pr_count' as const, threshold: 1 }, + }, + { + id: 'badge-10-prs', + name: '10 PRs Strong', + description: 'You've merged 10 pull requests. Impressive!', + icon: '💪', + path: 'builder' as const, + criteria: { type: 'pr_count' as const, threshold: 10 }, + }, + { + id: 'badge-first-mutual-aid', + name: 'Helping Hand', + description: 'Completed your first mutual aid transaction', + icon: '🤝', + path: 'community_heart' as const, + criteria: { type: 'mutual_aid_count' as const, threshold: 1 }, + }, +] +``` + +## Transaction Logging + +All reward awards must be logged to NDJSON: + +```typescript +// Log format +{ + "id": "uuid", + "timestamp": "2025-01-15T10:30:00Z", + "event_type": "reward_awarded", + "metadata": { + "reward_type": "badge", + "reward_id": "badge-first-pr", + "member_id": "member-123", + "reason": "PR #42 merged", + "member_handle": "alice_organizer" + } +} +``` + +Store logs in: `logs/rewards/transactions-YYYY-MM-DD.ndjson` + +## Visual Progression System + +Members progress through visual states based on contributions: + +```typescript +export type VisualState = 'seed' | 'seedling' | 'young_tree' | 'majestic_tree' + +export function calculateVisualState(contributionScore: number): VisualState { + if (contributionScore < 10) return 'seed' + if (contributionScore < 50) return 'seedling' + if (contributionScore < 200) return 'young_tree' + return 'majestic_tree' +} + +export function getContributionScore(member: Member): number { + let score = 0 + + // PR contributions + score += member.prsMerged * 5 + + // Mutual aid + score += member.mutualAidTransactions * 3 + + // Proposals created + score += member.proposalsCreated * 10 + + // Events organized + score += member.eventsOrganized * 15 + + return score +} +``` + +## Capability Unlocks + +Rewards can unlock new features: + +```typescript +export interface CapabilityUnlock { + capability: 'create_proposal' | 'organize_event' | 'moderate' | 'steward' + requirements: { + badges?: string[] + contributionScore?: number + paths?: string[] + } +} + +export function checkCapability( + member: Member, + capability: string +): boolean { + const unlock = CAPABILITY_UNLOCKS[capability] + if (!unlock) return false + + // Check badges + if (unlock.requirements.badges) { + const hasBadges = unlock.requirements.badges.every(badgeId => + member.badges.some(b => b.id === badgeId) + ) + if (!hasBadges) return false + } + + // Check contribution score + if (unlock.requirements.contributionScore) { + const score = getContributionScore(member) + if (score < unlock.requirements.contributionScore) return false + } + + // Check paths + if (unlock.requirements.paths) { + const hasPath = unlock.requirements.paths.some(path => + member.archetypes.includes(path) + ) + if (!hasPath) return false + } + + return true +} +``` + +## Documentation Updates + +After implementing a reward, update: + +1. **Module spec**: `docs/modules/rewards.md` - Add reward type to list +2. **Data models**: `packages/types/src/rewards.ts` - Export new interfaces +3. **Fixtures**: `packages/fixtures/src/badges.ts` - Add example data +4. **STATUS**: `docs/STATUS_v2.md` - Bump progress marker + +## Common Patterns + +### Auto-Award on Activity + +```typescript +// In governance handler after PR merge +export async function handlePRMerge(prId: string, memberId: string) { + // ... merge logic ... + + // Check for badge eligibility + const member = await memberRepo.findById(memberId) + const prCount = await getPRCount(memberId) + + if (prCount === 1) { + await awardBadge({ + badgeId: 'badge-first-pr', + memberId, + reason: `PR #${prId} merged`, + }, badgeRepo) + } +} +``` + +### Display Badge Progress + +```typescript +export function BadgeProgress({ badge, member }: Props) { + const progress = calculateProgress(badge, member) + + return ( +
+ +
+
+
+
+

+ {progress}% complete +

+
+
+ ) +} +``` + +## Validation Checklist + +Before submitting PR: + +- [ ] Entity model includes validation logic +- [ ] Repository interface defined with in-memory implementation +- [ ] Zod schemas created with proper constraints +- [ ] API handler validates input and handles errors +- [ ] UI component handles all states (loading, empty, error, success) +- [ ] Unit tests cover happy path and error cases +- [ ] Fixture data added for testing +- [ ] NDJSON transaction logging implemented +- [ ] Documentation updated (module spec, data models, STATUS) +- [ ] `./scripts/validate.sh` passes with: + - `LINT=OK` + - `VALIDATORS=GREEN` + - `SMOKE=OK` + +## References + +For detailed patterns and examples, see: +- **Reward Builder Guide**: `docs/dev/reward-module-guide.md` - Comprehensive templates and workflows +- **Data Models**: `packages/types/src/rewards.ts` - Complete type definitions +- **Social Economy**: Knowledge base document on gamification and progression systems diff --git a/packages/types/__tests__/rewards.test.ts b/packages/types/__tests__/rewards.test.ts new file mode 100644 index 00000000..fbc0ee55 --- /dev/null +++ b/packages/types/__tests__/rewards.test.ts @@ -0,0 +1,357 @@ +// packages/types/__tests__/rewards.test.ts +// TogetherOS Rewards Module - Entity Validation Tests + +import { describe, it, expect } from 'vitest' +import { + createRewardEventSchema, + rewardEventSchema, + memberRewardBalanceSchema, + badgeSchema, + memberBadgeSchema, + isValidEventType, + getSPWeight, + calculatePRSize, + generateDedupKey, +} from '@togetheros/validators/rewards' + +describe('createRewardEventSchema', () => { + describe('valid inputs', () => { + it('accepts valid PR merge event', () => { + const input = { + memberId: '550e8400-e29b-41d4-a716-446655440000', + event_type: 'pr_merged_small', + context: { + pr_number: 123, + repo: 'TogetherOS', + lines_changed: 42, + }, + source: 'github', + } + + const result = createRewardEventSchema.safeParse(input) + expect(result.success).toBe(true) + }) + + it('accepts valid docs contribution event', () => { + const input = { + memberId: '550e8400-e29b-41d4-a716-446655440001', + event_type: 'docs_contribution', + context: { + pr_number: 456, + repo: 'TogetherOS', + }, + source: 'github', + timestamp: new Date('2025-01-25T10:00:00Z'), + } + + const result = createRewardEventSchema.safeParse(input) + expect(result.success).toBe(true) + }) + + it('accepts minimal valid event', () => { + const input = { + memberId: '550e8400-e29b-41d4-a716-446655440002', + event_type: 'code_review', + context: {}, + source: 'manual', + } + + const result = createRewardEventSchema.safeParse(input) + expect(result.success).toBe(true) + }) + }) + + describe('validation errors', () => { + it('rejects invalid member ID', () => { + const input = { + memberId: 'not-a-uuid', + event_type: 'pr_merged_small', + context: {}, + source: 'github', + } + + const result = createRewardEventSchema.safeParse(input) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('UUID') + } + }) + + it('rejects invalid event type', () => { + const input = { + memberId: '550e8400-e29b-41d4-a716-446655440000', + event_type: 'invalid_type', + context: {}, + source: 'github', + } + + const result = createRewardEventSchema.safeParse(input) + expect(result.success).toBe(false) + }) + + it('rejects empty source', () => { + const input = { + memberId: '550e8400-e29b-41d4-a716-446655440000', + event_type: 'pr_merged_small', + context: {}, + source: '', + } + + const result = createRewardEventSchema.safeParse(input) + expect(result.success).toBe(false) + }) + + it('rejects negative PR number', () => { + const input = { + memberId: '550e8400-e29b-41d4-a716-446655440000', + event_type: 'pr_merged_small', + context: { pr_number: -1 }, + source: 'github', + } + + const result = createRewardEventSchema.safeParse(input) + expect(result.success).toBe(false) + }) + + it('rejects negative lines changed', () => { + const input = { + memberId: '550e8400-e29b-41d4-a716-446655440000', + event_type: 'pr_merged_small', + context: { lines_changed: -10 }, + source: 'github', + } + + const result = createRewardEventSchema.safeParse(input) + expect(result.success).toBe(false) + }) + }) +}) + +describe('rewardEventSchema', () => { + it('accepts complete reward event', () => { + const event = { + id: '550e8400-e29b-41d4-a716-446655440000', + memberId: '550e8400-e29b-41d4-a716-446655440001', + event_type: 'pr_merged_medium', + sp_weight: 10, + context: { + pr_number: 123, + repo: 'TogetherOS', + lines_changed: 150, + }, + source: 'github', + dedup_key: 'github::pr:123::repo:TogetherOS', + timestamp: new Date(), + status: 'processed', + processedAt: new Date(), + } + + const result = rewardEventSchema.safeParse(event) + expect(result.success).toBe(true) + }) + + it('rejects invalid SP weight', () => { + const event = { + id: '550e8400-e29b-41d4-a716-446655440000', + memberId: '550e8400-e29b-41d4-a716-446655440001', + event_type: 'pr_merged_medium', + sp_weight: 0, // Must be positive + context: {}, + source: 'github', + dedup_key: 'test', + timestamp: new Date(), + status: 'processed', + } + + const result = rewardEventSchema.safeParse(event) + expect(result.success).toBe(false) + }) + + it('rejects invalid status', () => { + const event = { + id: '550e8400-e29b-41d4-a716-446655440000', + memberId: '550e8400-e29b-41d4-a716-446655440001', + event_type: 'pr_merged_medium', + sp_weight: 10, + context: {}, + source: 'github', + dedup_key: 'test', + timestamp: new Date(), + status: 'invalid_status', + } + + const result = rewardEventSchema.safeParse(event) + expect(result.success).toBe(false) + }) +}) + +describe('memberRewardBalanceSchema', () => { + it('accepts valid balance', () => { + const balance = { + memberId: '550e8400-e29b-41d4-a716-446655440000', + total: 100, + available: 70, + allocated: 30, + updatedAt: new Date(), + } + + const result = memberRewardBalanceSchema.safeParse(balance) + expect(result.success).toBe(true) + }) + + it('rejects mismatched totals', () => { + const balance = { + memberId: '550e8400-e29b-41d4-a716-446655440000', + total: 100, + available: 70, + allocated: 20, // Should be 30 + updatedAt: new Date(), + } + + const result = memberRewardBalanceSchema.safeParse(balance) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('available + allocated') + } + }) + + it('rejects negative values', () => { + const balance = { + memberId: '550e8400-e29b-41d4-a716-446655440000', + total: 100, + available: -10, + allocated: 110, + updatedAt: new Date(), + } + + const result = memberRewardBalanceSchema.safeParse(balance) + expect(result.success).toBe(false) + }) +}) + +describe('badgeSchema', () => { + it('accepts valid badge', () => { + const badge = { + id: '550e8400-e29b-41d4-a716-446655440000', + name: 'First PR', + description: 'Merged your first pull request', + icon: '🎉', + criteria: 'Merge at least one pull request', + category: 'milestone', + } + + const result = badgeSchema.safeParse(badge) + expect(result.success).toBe(true) + }) + + it('rejects invalid category', () => { + const badge = { + id: '550e8400-e29b-41d4-a716-446655440000', + name: 'First PR', + description: 'Merged your first pull request', + icon: '🎉', + criteria: 'Merge at least one pull request', + category: 'invalid', + } + + const result = badgeSchema.safeParse(badge) + expect(result.success).toBe(false) + }) + + it('rejects short name', () => { + const badge = { + id: '550e8400-e29b-41d4-a716-446655440000', + name: 'AB', // Too short + description: 'Merged your first pull request', + icon: '🎉', + criteria: 'Merge at least one pull request', + category: 'milestone', + } + + const result = badgeSchema.safeParse(badge) + expect(result.success).toBe(false) + }) +}) + +describe('helper functions', () => { + describe('isValidEventType', () => { + it('returns true for valid types', () => { + expect(isValidEventType('pr_merged_small')).toBe(true) + expect(isValidEventType('docs_contribution')).toBe(true) + expect(isValidEventType('bug_fix')).toBe(true) + }) + + it('returns false for invalid types', () => { + expect(isValidEventType('invalid_type')).toBe(false) + expect(isValidEventType('')).toBe(false) + expect(isValidEventType('pr_merged')).toBe(false) + }) + }) + + describe('getSPWeight', () => { + it('returns correct weights', () => { + expect(getSPWeight('pr_merged_small')).toBe(5) + expect(getSPWeight('pr_merged_medium')).toBe(10) + expect(getSPWeight('pr_merged_large')).toBe(20) + expect(getSPWeight('docs_contribution')).toBe(8) + expect(getSPWeight('code_review')).toBe(3) + expect(getSPWeight('issue_triage')).toBe(2) + expect(getSPWeight('bug_fix')).toBe(15) + }) + }) + + describe('calculatePRSize', () => { + it('returns small for < 50 lines', () => { + expect(calculatePRSize(0)).toBe('small') + expect(calculatePRSize(25)).toBe('small') + expect(calculatePRSize(49)).toBe('small') + }) + + it('returns medium for 50-199 lines', () => { + expect(calculatePRSize(50)).toBe('medium') + expect(calculatePRSize(100)).toBe('medium') + expect(calculatePRSize(199)).toBe('medium') + }) + + it('returns large for >= 200 lines', () => { + expect(calculatePRSize(200)).toBe('large') + expect(calculatePRSize(500)).toBe('large') + expect(calculatePRSize(1000)).toBe('large') + }) + }) + + describe('generateDedupKey', () => { + it('generates key from PR context', () => { + const key = generateDedupKey('github', { + pr_number: 123, + repo: 'TogetherOS', + }) + expect(key).toBe('github::pr:123::repo:TogetherOS') + }) + + it('generates key from issue context', () => { + const key = generateDedupKey('github', { + issue_number: 456, + repo: 'TogetherOS', + }) + expect(key).toBe('github::issue:456::repo:TogetherOS') + }) + + it('generates key from source only', () => { + const key = generateDedupKey('manual', {}) + expect(key).toBe('manual') + }) + + it('generates consistent keys', () => { + const key1 = generateDedupKey('github', { + pr_number: 123, + repo: 'TogetherOS', + }) + const key2 = generateDedupKey('github', { + pr_number: 123, + repo: 'TogetherOS', + }) + expect(key1).toBe(key2) + }) + }) +}) diff --git a/packages/types/src/rewards.ts b/packages/types/src/rewards.ts new file mode 100644 index 00000000..6740e2f2 --- /dev/null +++ b/packages/types/src/rewards.ts @@ -0,0 +1,148 @@ +// packages/types/src/rewards.ts +// TogetherOS Rewards Module - Core Entity Definitions + +/** + * Event types that trigger Support Point rewards + */ +export type RewardEventType = + | 'pr_merged_small' // < 50 lines + | 'pr_merged_medium' // 50-200 lines + | 'pr_merged_large' // > 200 lines + | 'docs_contribution' // Documentation updates + | 'code_review' // PR review completed + | 'issue_triage' // Issue labeled/prioritized + | 'bug_fix' // Bug fix merged + +/** + * Domain-specific context for reward events + */ +export interface EventContext { + // GitHub-specific + pr_number?: number + issue_number?: number + repo?: string + lines_changed?: number + + // Generic metadata + [key: string]: string | number | boolean | undefined +} + +/** + * Core reward event entity + * Represents a single contribution that earns Support Points + */ +export interface RewardEvent { + /** Unique identifier (UUID v4) */ + id: string + + /** Member who earned the reward */ + memberId: string + + /** Type of contribution */ + event_type: RewardEventType + + /** Support Points awarded (calculated from event_type) */ + sp_weight: number + + /** Domain-specific context */ + context: EventContext + + /** Event source (e.g., 'github', 'manual') */ + source: string + + /** Deduplication key (source + context) */ + dedup_key: string + + /** When the event occurred */ + timestamp: Date + + /** Processing status */ + status: 'pending' | 'processed' | 'failed' + + /** When event was processed */ + processedAt?: Date +} + +/** + * Member's Support Points balance + */ +export interface MemberRewardBalance { + /** Member ID */ + memberId: string + + /** Total SP earned (all time) */ + total: number + + /** Available SP (not allocated to proposals) */ + available: number + + /** SP allocated to active proposals */ + allocated: number + + /** Last updated timestamp */ + updatedAt: Date +} + +/** + * Badge achievement definition + */ +export interface Badge { + /** Unique badge ID */ + id: string + + /** Display name */ + name: string + + /** Description of achievement */ + description: string + + /** Icon (emoji or URL) */ + icon: string + + /** Criteria to earn badge */ + criteria: string + + /** Badge category */ + category: 'contribution' | 'milestone' | 'special' +} + +/** + * Member's earned badges + */ +export interface MemberBadge { + /** Member ID */ + memberId: string + + /** Badge ID */ + badgeId: string + + /** When badge was earned */ + earnedAt: Date + + /** Related event ID (if applicable) */ + eventId?: string +} + +/** + * Input for creating a new reward event + */ +export interface CreateRewardEventInput { + memberId: string + event_type: RewardEventType + context: EventContext + source: string + timestamp?: Date +} + +/** + * SP weight mapping for event types + */ +export const SP_WEIGHTS: Record = { + pr_merged_small: 5, + pr_merged_medium: 10, + pr_merged_large: 20, + docs_contribution: 8, + code_review: 3, + issue_triage: 2, + bug_fix: 15, +} diff --git a/packages/validators/src/rewards.ts b/packages/validators/src/rewards.ts new file mode 100644 index 00000000..7d707a5f --- /dev/null +++ b/packages/validators/src/rewards.ts @@ -0,0 +1,152 @@ +// packages/validators/src/rewards.ts +// TogetherOS Rewards Module - Zod Validation Schemas + +import { z } from 'zod' +import type { RewardEventType, SP_WEIGHTS } from '@togetheros/types/rewards' + +/** + * Reward event type enum schema + */ +export const rewardEventTypeSchema = z.enum([ + 'pr_merged_small', + 'pr_merged_medium', + 'pr_merged_large', + 'docs_contribution', + 'code_review', + 'issue_triage', + 'bug_fix', +]) + +/** + * Event context schema + * Flexible object for domain-specific metadata + */ +export const eventContextSchema = z.object({ + pr_number: z.number().int().positive().optional(), + issue_number: z.number().int().positive().optional(), + repo: z.string().min(1).max(200).optional(), + lines_changed: z.number().int().nonnegative().optional(), +}).catchall(z.union([z.string(), z.number(), z.boolean()])) + +/** + * Create reward event input schema + * Validates input for creating new reward events + */ +export const createRewardEventSchema = z.object({ + memberId: z.string().uuid('Member ID must be a valid UUID'), + event_type: rewardEventTypeSchema, + context: eventContextSchema, + source: z.string().min(1).max(50), + timestamp: z.coerce.date().optional(), +}) + +/** + * Type inference from schema + */ +export type CreateRewardEventInput = z.infer + +/** + * Full reward event schema (including generated fields) + */ +export const rewardEventSchema = z.object({ + id: z.string().uuid(), + memberId: z.string().uuid(), + event_type: rewardEventTypeSchema, + sp_weight: z.number().int().positive(), + context: eventContextSchema, + source: z.string().min(1).max(50), + dedup_key: z.string().min(1), + timestamp: z.coerce.date(), + status: z.enum(['pending', 'processed', 'failed']), + processedAt: z.coerce.date().optional(), +}) + +/** + * Type inference from schema + */ +export type RewardEvent = z.infer + +/** + * Member reward balance schema + */ +export const memberRewardBalanceSchema = z.object({ + memberId: z.string().uuid(), + total: z.number().int().nonnegative(), + available: z.number().int().nonnegative(), + allocated: z.number().int().nonnegative(), + updatedAt: z.coerce.date(), +}).refine( + (data) => data.total === data.available + data.allocated, + { + message: 'Total SP must equal available + allocated', + path: ['total'], + } +) + +/** + * Badge schema + */ +export const badgeSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(3).max(50), + description: z.string().min(10).max(500), + icon: z.string().min(1).max(200), + criteria: z.string().min(10).max(500), + category: z.enum(['contribution', 'milestone', 'special']), +}) + +/** + * Member badge schema + */ +export const memberBadgeSchema = z.object({ + memberId: z.string().uuid(), + badgeId: z.string().uuid(), + earnedAt: z.coerce.date(), + eventId: z.string().uuid().optional(), +}) + +/** + * Validation helper: Check if event type is valid + */ +export function isValidEventType(type: string): type is RewardEventType { + return rewardEventTypeSchema.safeParse(type).success +} + +/** + * Validation helper: Get SP weight for event type + */ +export function getSPWeight(eventType: RewardEventType): number { + const weights: Record = { + pr_merged_small: 5, + pr_merged_medium: 10, + pr_merged_large: 20, + docs_contribution: 8, + code_review: 3, + issue_triage: 2, + bug_fix: 15, + } + return weights[eventType] +} + +/** + * Validation helper: Calculate PR size category + */ +export function calculatePRSize(linesChanged: number): 'small' | 'medium' | 'large' { + if (linesChanged < 50) return 'small' + if (linesChanged < 200) return 'medium' + return 'large' +} + +/** + * Validation helper: Generate deduplication key + */ +export function generateDedupKey(source: string, context: Record): string { + // Create stable key from source + relevant context fields + const keyParts = [source] + + if (context.pr_number) keyParts.push(`pr:${context.pr_number}`) + if (context.issue_number) keyParts.push(`issue:${context.issue_number}`) + if (context.repo) keyParts.push(`repo:${context.repo}`) + + return keyParts.join('::') +}