BuildMesh started as an offline, AI-assisted requirement-analysis and test-case-generation platform (Phases 0–3 below) and has since grown into a full multiplayer Scrum simulation game built on top of it: real users join a session, take on real Scrum roles (Scrum Master, Developer, Tester, plus an AI-controlled Product Owner), and run real sprints — planning, executing, facing simulated events and pressure, reviewing outcomes, holding retrospectives, and seeing their decisions measured across sprints — all against one real, persistent backlog, not a disconnected game state.
Fully offline and self-contained — no required external APIs (an optional local Ollama LLM can be enabled; everything has a deterministic rule-based fallback otherwise). JWT authentication, two independent role systems (account-level RBAC and in-session Scrum roles), SQLite, and a Next.js frontend.
What it solves: turns "read about Scrum" into "run a sprint and see what actually happens" — a team can practice real sprint planning, backlog grooming, QA, change management, and retrospectives with consequences (schedule pressure, defects, scope creep, risk) that are computed deterministically from what the team actually does, not scripted or random-for-its-own-sake.
Major functionality (see each phase's section below for detail): requirement/test-case authoring with an offline NLP pipeline (Phase 3); multiplayer sessions with join codes and Scrum roles (Phase 1); a real backlog — epics, stories, sprints, tasks, bugs, QA (Phase 2); an AI-driven Product Owner and change-request pipeline (Phase 4); live WebSocket updates (Phase 5); XP/achievements (Phase 6); a deterministic, replayable sprint simulation with events and risk (Phases 7–8); retrospectives with real follow-through (Phase 9); a multi-sprint history/trend view (Phase 10); and a sprint-planning assistant that surfaces a team's own velocity at the moment it plans its next sprint (Phase 11).
Everything below has been run and verified against a fresh clone of this exact repository — no step is invented.
Prerequisites: Python 3.9+, Node.js 18.17+ (required by Next.js 14),
and git. No external services, API keys, or paid accounts are needed —
BuildMesh is fully offline by default.
git clone https://github.com/soyebmohammad03-dev/BuildMesh.git
cd BuildMeshAlready done by cd BuildMesh above. All commands below assume you are
in this root directory (the one containing main.py, requirements.txt,
and the frontend/ folder) unless a step says otherwise.
The backend lives at the repository root (api/, core/, db/,
engines/, main.py) — there is no separate backend/ folder.
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txtThe frontend is the Next.js app in frontend/.
cd frontend
npm install
cd ..Copy the example files and adjust if needed — the defaults below are enough to run everything locally with no changes.
cp .env.example .env
cp frontend/.env.example frontend/.env.local| Variable | Where | Required locally? | Purpose |
|---|---|---|---|
SECRET_KEY |
.env |
Optional locally | JWT signing key. Falls back to a development default if unset (a startup warning is only logged when DEBUG=false). Must be set to a real random value for any non-local deployment. |
DEBUG |
.env |
Optional (default false) |
Set to true for backend auto-reload during local development. |
NEXT_PUBLIC_API_URL |
frontend/.env.local |
Yes | The backend URL the frontend calls. Defaults to http://127.0.0.1:8001, which matches the backend command below — leave it as-is for local use. |
CORS_ALLOWED_ORIGINS |
.env |
Optional | Comma-separated allowed frontend origins. Unset, the backend already accepts the default local frontend origins (localhost:3000/3002), so this is not needed for local development. |
BUILDMESH_DB_URL_OVERRIDE |
.env |
Optional | Relocates the SQLite database file. Leave unset to use the default data/requirements.db. |
PORT |
.env |
Optional (default 8001) |
Backend port when started via python3 main.py. |
ADMIN_USERNAME / ADMIN_EMAIL / ADMIN_PASSWORD |
.env |
Optional | Overrides for the admin-seeding step below. Leave unset locally to get admin / admin123. |
LLM_ENABLED / LLM_PROVIDER / OLLAMA_* |
.env |
Optional | Enables an optional local Ollama LLM (Phase 3). Everything works with deterministic fallback logic if left at the defaults — no LLM installation is required to run or test BuildMesh. |
None of these need to contain a real secret to run BuildMesh locally —
the placeholder values in .env.example are sufficient.
alembic upgrade headThis creates data/requirements.db (SQLite) with the full current
schema if it doesn't exist yet, or brings an existing one up to date.
See Database Migrations further below for how schema changes are
made after this point.
python scripts/seed_admin.pyCreates an admin / admin123 account (or your ADMIN_* overrides from
step 5) so you have an account to log in with immediately. This step is
optional — you can also just register a normal account through the app
itself — but an admin account is needed to create the first session as
its host.
Terminal 1:
python3 main.pyThis is the real entry point (main.py) and already sets the
--ws websockets-sansio flag the real-time layer requires — see
Real-Time Layer (Phase 5) below for why that flag matters. Runs on
http://127.0.0.1:8001 by default.
- API docs: http://127.0.0.1:8001/docs
- ReDoc: http://127.0.0.1:8001/redoc
- Health check: http://127.0.0.1:8001/health
(Equivalent alternative, e.g. for --reload during active backend
development: python3 -m uvicorn api.main:app --reload --port 8001 --ws websockets-sansio.)
Terminal 2 (leave Terminal 1 running):
cd frontend
npm run dev:frontendRuns on http://localhost:3002.
Visit http://localhost:3002/login in your browser.
If you ran the admin-seeding step: admin / admin123.
This is a well-known local-development default, not a production
credential — scripts/seed_admin.py prints a warning if you use it, and
you should override it with ADMIN_PASSWORD (step 5) for anything
beyond your own machine. You can also skip seeding entirely and register
your own account from the login page (self-registration can create
VIEWER, QA_ENGINEER, or REQUIREMENT_ANALYST accounts — see
Security notes below).
ModuleNotFoundError/ import errors on backend start — make sure the virtual environment is activated (source venv/bin/activate) andpip install -r requirements.txtcompleted without errors.- Frontend can't reach the backend / requests fail — confirm the
backend is actually running on port 8001 (
curl http://127.0.0.1:8001/health) and thatfrontend/.env.localhasNEXT_PUBLIC_API_URL=http://127.0.0.1:8001. - WebSocket/real-time updates don't work, or the backend crashes on a
disconnect — you're almost certainly missing
--ws websockets-sansio.python3 main.pyalready sets this; a manualuvicorninvocation must set it explicitly (step 8's alternative command shows the flag). alembic upgrade headcomplains tables already exist — this usually means the app was started once before migrations were ever run against a brand-new database file. See Database Migrations below for why ordering matters here.- Port already in use — another process is already using 8001 or
3002. Stop it, or override the backend port with
PORT=<other-port>(step 5) and updateNEXT_PUBLIC_API_URLto match; the frontend port can be changed by editing thedev:frontend/startscripts infrontend/package.json. - Login works but nothing loads on a session page — check the browser console and the backend terminal for errors; every session page needs an active WebSocket connection in addition to the REST API, so confirm both servers are actually running.
BuildMesh/
├── api/
│ ├── main.py # FastAPI app entry
│ ├── auth.py # Registration, login, JWT, logout
│ ├── dependencies.py # get_current_user, auth dependencies
│ └── routes/
│ ├── auth_routes.py # POST /api/auth/register, login, logout, me
│ ├── requirement_routes.py # Upload SRS, list, edit requirements
│ ├── testcase_routes.py # Generate, list, edit test cases
│ ├── dashboard_routes.py # Metrics, projects
│ ├── feedback_routes.py # Submit corrections, stats
│ ├── admin_routes.py # User management (Admin only)
│ └── ws_routes.py # Phase 5: session-scoped WebSocket channel
│
├── core/
│ ├── security.py # bcrypt, JWT
│ ├── rbac.py # Role-based permissions
│ └── realtime/ # Phase 5: in-process connection manager + event model
│
├── db/
│ ├── database.py # SQLite, SessionLocal
│ └── models.py # Users, Projects, Requirements, TestCases, FeedbackLogs, TraceabilityMap
│
├── engines/ # NLP engines (offline)
│ ├── requirement_processor.py
│ ├── test_case_generator.py
│ ├── ambiguity_detector.py
│ ├── traceability.py
│ └── feedback_learning.py
│
├── services/
│ └── document_parser.py # Extract text from .txt, .docx, .pdf
│
├── static/
├── templates/
├── data/
└── evaluation/
| Role | Permissions |
|---|---|
| ADMIN | Full access, manage users, view analytics, delete projects |
| QA_ENGINEER | Generate test cases, view dashboard, submit feedback, upload documents |
| REQUIREMENT_ANALYST | Upload SRS, view ambiguity reports, edit requirements |
| VIEWER | Read-only dashboard |
- users — id, username, email, hashed_password, role, created_at
- projects — id, name, description, owner_id
- requirements — id, requirement_id, project_id, raw_text, actors, actions, inputs, ...
- test_cases — id, test_id, requirement_id, category, title, steps, expected_result, ...
- feedback_logs — id, user_id, test_id, original/corrected fields
- traceability_map — id, requirement_id, test_id, category, confidence
- token_blacklist — logout token invalidation
- Register —
POST /api/auth/register(username, email, password, role) — self-registration can only grantVIEWER,QA_ENGINEER, orREQUIREMENT_ANALYST; see Security Notes below. - Login —
POST /api/auth/login→ JWT token - Create project —
POST /api/dashboard/projects(name, description) - Upload SRS —
POST /api/requirements/upload(file: .txt/.docx/.pdf, project_id) - Generate test cases —
POST /api/testcases/generate?requirement_id=REQ-xxx - View dashboard —
GET /api/dashboard/metrics?project_id=1 - Edit test case —
PUT /api/testcases/{test_id}(title, steps, expected_result) - Submit feedback —
POST /api/feedback/submit(original/corrected)
All protected endpoints require header: Authorization: Bearer <token>
- Self-registration cannot grant
ADMIN.POST /api/auth/registeraccepts arole, but a request forADMIN(in any casing) is rejected with403; the role is otherwise restricted toVIEWER,QA_ENGINEER, orREQUIREMENT_ANALYST, falling back toVIEWERfor anything unrecognized. This closes a privilege-escalation path where any unauthenticated caller could previously self-assignADMIN. The intended way to create anADMINaccount is stillpython scripts/seed_admin.py(bootstrap) orPOST /api/admin/users(by an existing admin) — both are unaffected. - Account role (
ADMIN/QA_ENGINEER/REQUIREMENT_ANALYST/VIEWER) is a platform-level permission tier, deliberately kept separate from the future in-session game roles (Owner/AI, Scrum Master, Developer, Tester) described in the BuildMesh architecture blueprint — the two are not the same concept and will not be merged.
Schema changes are managed with Alembic (alembic/), not
Base.metadata.create_all() alone:
alembic current # show applied revision
alembic revision --autogenerate -m "add X" # after changing db/models.py
alembic upgrade head # apply pending migrationsBase.metadata.create_all() (called once at app startup via init_db()) is
left in place as a convenience for a completely fresh checkout with no
database file yet — it is idempotent and never alters existing tables. Any
change to an existing table must go through a migration, since
create_all() cannot do that. Always run alembic upgrade head before
first starting the app against a brand-new database file: if the app
starts first, create_all() will create the current schema directly
without stamping an Alembic revision, and a later alembic upgrade head
against that same file will then fail trying to re-create tables that
already exist.
The game layer's foundation: a GameSession is the "room" a join code
admits players to, independent of (and simpler than) the account-level
auth system above. POST /api/sessions creates one (real host, real join
code via core/join_codes.py); POST /api/sessions/join (join code) and
POST /api/sessions/{id}/role (pick SCRUM_MASTER / DEVELOPER /
TESTER — OWNER is AI-controlled, see Phase 4) get a user into it.
SESSION_ROLE_CAPACITY caps how many players can hold each role at once.
Two roles, deliberately never merged: a user's account-level UserRole
(ADMIN/QA_ENGINEER/REQUIREMENT_ANALYST/VIEWER, from the original
platform above) and their in-session SessionRole are independent
concepts, checked by entirely separate authorization code paths
(core/rbac.py vs. api/session_deps.py) — an account being ADMIN
never grants any session-scoped privilege, and every phase built since
has been tested to confirm this boundary holds (see each phase's
authorization tests).
A real Scrum backlog scoped to one session: Epics → User Stories →
Tasks, plus Bugs and Test Executions, all in api/routes/backlog_routes.py
and qa_routes.py. Sprints move through PLANNED → ACTIVE → CLOSED
(POST .../sprints, .../start, .../close); stories move into a sprint
explicitly (.../stories/{id}/move-to-sprint); tasks are assigned and
tracked through real status transitions
(TO_DO → IN_PROGRESS → READY_FOR_TEST → DONE, or BLOCKED). QA records
real TestExecution rows against generated test cases and can file real
Bugs from a failure. This backlog is the single real data source every
later phase (simulation, metrics, retrospectives, the Project Arc) reads
from — nothing in the game layer keeps a separate copy of "what work
exists."
The original requirement-analysis/test-case-generation engines
(engines/) gained an optional local LLM backend (core/llm/, Ollama
only) alongside their original deterministic, rule-based logic. Every
generation result is honestly labeled with its real generation_method
(LLM_GENERATED or FALLBACK) — never presented as AI-generated when it
wasn't. LLM_ENABLED=false is a hard kill switch forcing every engine
onto its fallback (what the test suite uses, so unit tests never depend
on a live model); LLM_PROVIDER/OLLAMA_BASE_URL/OLLAMA_MODEL/
LLM_TIMEOUT_SECONDS configure the provider when enabled (see
.env.example). This same fallback-first design is what every later
phase's AI-driven feature (the Owner below, in-sprint scope events) also
relies on: the platform must produce a real, usable, clearly-labeled
result whether or not Ollama is actually running.
An AI-controlled OWNER session role (api/routes/owner_routes.py,
engines/owner_engine.py) that plays the Product Owner: POST .../owner/generate-srs turns a one-line project idea into an initial set
of real Requirement rows (via the Phase 3 pipeline, honestly labeled
FALLBACK or LLM_GENERATED). Stakeholder change requests go through a
real, auditable lifecycle — DRAFT → PROPOSED → ACCEPTED/REJECTED
(ChangeRequest/ChangeRequestImpact) — where the Scrum Master, a real
human, is the one who decides (.../accept / .../reject); accepting one
mechanically applies its proposed actions to the real backlog
(engines/change_engine.py: new/modified requirements, stories, tasks,
priority changes — never a narrative-only effect). This propose/decide
shape is the same pattern Phase 9's retrospective action items reuse.
A session-scoped WebSocket channel makes the BuildMesh lobby, backlog, task board, and AI Owner pages update live for every connected member, instead of relying only on polling. This is explicitly an in-process MVP, not a distributed real-time architecture — see "What's deferred" below.
- One endpoint:
GET /api/ws/sessions/{session_id}(an actual WebSocket upgrade, not REST). Connecting to it subscribes you to that session's events only — there is no "subscribe to everything" or cross-session channel. core/realtime/manager.py'sConnectionManageris a single in-process, in-memory registry ofsession_id -> user_id -> live sockets. It is not backed by a database table and not shared across processes.core/realtime/events.pydefines a small, closedEventTypeenum and anemit(session_id, event_type, data, actor_user_id)helper. REST routes callemit()once, explicitly, right after theirdb.commit()succeeds — nothing is broadcast automatically from generic DB writes.- The database is always the source of truth. An event's
datapayload is a short summary (the affected id(s) and its new state) — clients are expected to refetch the authoritative REST resource for anything more detailed, not reconstruct state purely from the event stream.
The browser's native WebSocket API cannot set an Authorization
header, and a token in the connection URL would end up in server/proxy
access logs — so authentication happens over the socket itself:
- Client opens
ws://.../api/ws/sessions/{id}. - Server calls
accept(), then waits (10s timeout) for exactly one JSON text frame:{"type": "auth", "token": "<jwt>"}. - The token is validated with the exact same logic
api/dependencies.py::get_current_useruses for REST (signature, expiry, blacklist, active user) — there is no second auth system. - The resolved user must then have an active
SessionMembershiprow for that specificsession_id— the same model/tableapi/session_deps.pyalready uses for every Phase 1-4 REST route. Session/game role (SessionRole) is still completely independent from account-levelUserRole/RBAC; the WebSocket layer checks the former only, exactly like REST. - Anything else — session doesn't exist, auth times out or fails, or the
user is authenticated but not a member of this session — closes the
socket immediately with a distinct code so a client can tell them
apart:
4404(not found),4401(unauthenticated),4403(forbidden).
Because authorization is just "active membership in this session_id", one session's events are structurally unreachable from another session's connection — there is no cross-session broadcast path to accidentally hit.
core/realtime/events.py::EventType — one member per meaningful existing
mutation, not "every write":
| Event | Emitted by |
|---|---|
member_joined, member_left, role_changed |
session_routes.py |
story_created, story_updated, story_moved |
backlog_routes.py |
sprint_created, sprint_started, sprint_closed |
backlog_routes.py |
task_created, task_assigned, task_status_changed |
backlog_routes.py |
test_execution_recorded, bug_filed, bug_status_changed |
qa_routes.py |
change_request_updated |
owner_routes.py (submitted/proposed/accepted/rejected — the status field in data distinguishes which) |
presence_update |
ws_routes.py itself, on every connect/disconnect |
Every message has the same envelope:
{"type": "story_created", "session_id": 6, "data": {"story": {...}}, "actor_user_id": 3, "ts": "2026-08-25T09:52:21.768239"}"Online" is derived, not stored: it is just "has a live WebSocket
connection right now" per ConnectionManager. The member roster and
roles still come from the real SessionMembership rows. No new table
exists for this, and no Redis/pub-sub was introduced — the brief for this
phase explicitly asked for neither.
frontend/lib/useSessionSocket.ts is a small hook: connect, send the
auth frame, call the caller's onEvent(event) for every message, and
auto-reconnect (3s backoff) on drop. It does not parse events into
partial UI state — pages that care about a given event type just refetch
their existing REST resource:
- Lobby (
sessions/[id]/page.tsx): renders a live online/offline dot per participant frompresence_update; refetches the roster onmember_joined/member_left/role_changed. - Backlog (
sessions/[id]/backlog/page.tsx): refetches on story/sprint/task creation and movement events. - Board (
sessions/[id]/board/page.tsx): refetches on task/QA/bug events. - AI Owner (
sessions/[id]/owner/page.tsx): refetches onchange_request_updated.
Polling still exists on every page as a fallback for a dropped/ reconnecting socket, just at a much longer interval (15-20s) than before Phase 5 — the WebSocket event is what makes the update feel immediate.
# Terminal 1
python3 -m uvicorn api.main:app --port 8001 --ws websockets-sansio
# Terminal 2
npm --prefix frontend run dev:frontendOpen the same session in two browsers (or one browser + a real WebSocket
client for the second participant — tests/test_realtime.py's
live_server fixture does exactly this with the websockets Python
package) and perform a backlog/task/QA action as one member; the other
member's page updates without a manual refresh.
Automated tests: pytest tests/test_realtime.py -v — connection-lifecycle
and authorization tests run against the in-process TestClient; tests
that need one connection's mutation to reach another live connection
run against a real uvicorn.Server on a loopback port (TestClient runs
every call on its own separate event loop, so it cannot exercise
cross-connection broadcast — see that file's module docstring for the
full reasoning).
The default uvicorn WebSocket implementation (--ws websockets, uvicorn's
adapter over the now-deprecated websockets.legacy module) crashed the
entire server process under a specific race: a client disconnecting
at the same moment the server also decided to close the connection (e.g.
an auth timeout) triggered a native-level fault after an initial
RuntimeError ("got a second 'websocket.close'"). This was reproduced
against the real dev server during manual E2E testing, not just
theorized. Two independent fixes are in place: (1) ws_routes.py no
longer calls close() after a WebSocketDisconnect, and every close()
call is wrapped so a close-related exception can never propagate; (2) the
app is run with --ws websockets-sansio, uvicorn's newer, non-legacy
implementation, which does not exhibit this fault. Run uvicorn with
--ws websockets-sansio in every environment, not just as a
nice-to-have — see "Technical debt" in the Phase 5 report for more detail
on what's still not verified about this dependency combination.
This is a single-process, in-memory implementation. Explicitly out of scope for this phase (see the Phase 5 completion report for the full list): horizontal scaling / multiple backend processes, Redis or any pub/sub broker, connection persistence across a process restart, event replay / guaranteed delivery, offline client queuing, richer presence (idle/away, typing indicators), and spectator/read-only connections. Running more than one API process today would mean a client connected to process A never sees an event caused by a request handled by process B — acceptable for the current single-process MVP/dev deployment, not for a horizontally-scaled one.
Session/team metrics, an XP ledger, and a small achievement system — all derived from real backlog/QA/AI-Owner records, never from a separately tracked "score."
core/metrics/engine.py— read-only calculations: completion, velocity, burndown, defects, QA/test metrics, player contribution, team aggregate. Velocity usesUserStory.story_pointswhen a session actually sets them, and always also reports a plain stories/tasks-completed count as an honest fallback for sessions that never set points. Burndown is approximated from each task's current status/updated_at— the schema has no persisted daily snapshot, so this is documented in the API response itself (burndown.limitation), not hidden.core/metrics/xp_rules.py— the one central XP rule table (award_xp()is the only function that ever inserts a ledger row).db.models.XpLedgerEntryis append-only and idempotent by a DB unique constraint on(session_id, user_id, event_type, source_type, source_id)— the same real event can never pay out twice.core/metrics/achievements.py— a small static catalog (7 achievements) with deterministic, DB-state-based unlock conditions;db.models.AchievementUnlockpersists each unlock once per(session_id, user_id, achievement_key).core/metrics/hooks.py— the only place that callsaward_xp()/unlock_achievement(), one function per real action (task completed, story completed, test executed, bug reported/resolved, sprint closed, an AI Owner change request accepted). Route handlers inbacklog_routes.py,qa_routes.py, andowner_routes.pyeach call exactly one hook, right after the state change that earned it.- Change-request XP is scaled by the real number of created/modified
entities from
engines/change_engine.py's application summary, not a flat reward for clicking Accept — seehooks.py::on_change_request_accepted. GET /api/sessions/{id}/metrics,.../metrics/sprints/{sprint_id},.../metrics/players,.../achievements— all read-only, gated by active session membership like every other Phase 2-5 route.- The dashboard (
frontend/app/sessions/[id]/metrics/page.tsx) refetches on the relevant Phase 5 WebSocket event types (including two new ones,xp_awardedandachievement_unlocked, emitted only fromcore/metrics/hooks.py) rather than polling aggressively.
Turns a sprint from a static Scrum board into a playable, server- authoritative simulation — deterministic, replayable, and grounded in the same real backlog/QA/AI-Owner mechanics as every earlier phase.
core/simulation/events.py— 5 event types (PRODUCTION_BUG,BLOCKED_TASK,SCOPE_INCREASE,PRIORITY_CHANGE,RESOURCE_CONSTRAINT), each a real consequence on an existing entity (a realBug, a realTask.status = BLOCKED, a realChangeRequestrouted through the unmodified Phase 4AIOwnerEngine, a realUserStory.prioritychange, a real capacity deduction). Generation israndom.Random(f"{sprint.rng_seed}:{turn}")— deterministic and replayable, never real randomness;rng_seedis fixed to the sprint's own id atstart_sprint, never client-supplied.core/simulation/engine.py—advance_turn()(the one new player action: incrementsSprint.current_turnand maybe generates one event),capacity_metrics()(planned/completed/remaining points plus an effective-capacity figure that accounts forRESOURCE_CONSTRAINTevents), andactivity_feed()(a chronological merge of real timestamped records — never fabricated entries).core/simulation/scoring.py—compute_sprint_outcome()combines Phase 6's existing metrics (never a second metrics system) into five named, weighted dimensions (delivery/quality/reliability/scope/process) plus one overall score, persisted once per sprint inSprintOutcome.core/simulation/hooks.py— resolves an event only when its real underlying entity actually changes (a blocked task leavesBLOCKED, a bug reachesRESOLVED/CLOSED, a change request is decided) — a player can never simply declare an event resolved. Feeds a small outcome-scaled XP bonus and a "Crisis Manager" achievement into the existing Phase 6 XP ledger/achievement catalog — no parallel scoring system.- Two new endpoints:
POST .../sprints/{id}/turns/advance(Scrum Master) andGET .../sprints/{id}/simulation(any member) — every other simulation action reuses an existing Phase 2/4 endpoint (task status, bug status, change-request accept/reject). - The dashboard (
frontend/app/sessions/[id]/simulation/page.tsx) shows turn/status, capacity, pending/resolved events with resolution hints, the activity feed, and — once closed — the Sprint Review outcome, all refreshed live over the existing Phase 5 WebSocket layer.
Layers a health/risk model, a persisted replay history, richer analytics, and two new event types onto Phase 7's simulation — reusing its engine, never a second simulation system.
core/simulation/events.py— two new event types,QUALITY_REGRESSION(eligible once a task's most recentTestExecutionis a realFAIL; resolved only by a genuine subsequentPASSon the same task — no click-resolve shortcut) andSTAKEHOLDER_ESCALATION(eligible once 3+ simulation events are simultaneouslyPENDING; auto-resolves the moment real player action drops the pending count back below that threshold). The flat Phase 7EVENT_CHANCE_PER_TURNconstant is replaced byevent_chance(), a pure function of real DB pressure signals (unresolved events, blocked tasks, open bugs) built from named constants — still fully deterministic under the same seeded RNG.core/simulation/health.py— a live, always-recomputable risk model across 5 named dimensions (delivery/quality/capacity/scope/process), distinct fromscoring.py's once-at-close outcome: every formula reads real DB facts (completion %, QA pass rate, capacity reduction, pending vs. accepted scope events, unresolved-event ratio) through Phase 6's existing metrics engine, never a duplicate one.core/simulation/history.py+ the newSprintTurnSnapshottable — one immutable row per turn actually advanced (its capacity/health snapshot and which event, if any, it produced), the minimal real record a replay needs rather than a full DB snapshot.GET .../sprints/{id}/historyis strictly read-only — replay can never mutate a sprint.core/simulation/timeline.py— merges Phase 7'sactivity_feed()with change-request decisions, XP awards, and achievement unlocks from the sprint's real active window into one chronological record.core/simulation/analytics.py— per-player/team simulation analytics (events resolved, by type, crisis-resolution counts, resolution share) layered on top of — never replacing — Phase 6'splayer_contribution().- Three new read-only endpoints:
GET .../sprints/{id}/history,.../timeline, and.../analytics, plus one new realtime event,risk_changed, emitted alongside every turn advance over the existing Phase 5 WebSocket layer. - The simulation dashboard gained a Sprint Health risk-bar panel, an enriched Timeline card, and a read-only Turn History (Replay) stepper for stepping through a sprint's recorded turns.
Closes the Scrum ceremony loop: Phases 1-8 already cover Planning,
Execution, and Review (the persisted SprintOutcome — literally called
"the Sprint Review" in simulation_routes.py's own docstring); Phase 9
adds the missing Retrospective, and makes its outcome a real, measurable
consequence rather than a comment box.
core/retro/service.py— the real entry/action-item lifecycle: any active member submits aWENT_WELL/NEEDS_IMPROVEMENT/ACTION_ITEMentry against a sprint only once it's genuinelyCLOSED; anACTION_ITEMmovesOPEN -> ADOPTED -> APPLIED(orDISMISSED), mirroringChangeRequest's propose/decide shape. Entries are immutable once created, same audit-trail philosophy as Phase 8's turn snapshots.- Adoption is a Scrum-Master decision; auto-attachment binds every
session-wide
ADOPTEDitem to the next sprint the moment it actually starts (backlog_routes.py::start_sprint->core/retro/hooks.py:: on_sprint_started) — never client-supplied. Applying an item is open to any active member, but only for the sprint it's attached to, and only while that sprint is genuinelyACTIVE. core/simulation/health.py's live_process_risk()gets one small, named, capped reduction per realAPPLIEDaction item on the current sprint (PROCESS_RISK_REDUCTION_PER_APPLIED_ACTION, capped byPROCESS_RISK_REDUCTION_CAP) — additive to the existing formula; the once-at-closescoring.pyoutcome is deliberately left untouched.- XP for submitting an entry, for an authored action item being adopted,
and for applying one (
core/metrics/xp_rules.py), plus one new achievement, Retro Champion, unlocked when a player's own action item reachesAPPLIED— all through the existing Phase 6 ledger/catalog. - Two new realtime events,
retro_entry_submittedandretro_action_status_changed, over the existing Phase 5 WebSocket layer. - New endpoints under
api/routes/retro_routes.py: submit/list entries per sprint, adopt/dismiss/apply an action item, and a session-wideGET .../retrospective/action-items"Improvement Backlog" that also surfaces Phase 6's existingvelocity_metrics()— reused, not reimplemented. - A new
/sessions/[id]/retrospectivepage (linked from the session lobby) showing the selected closed sprint's three-column retrospective plus the session-wide Improvement Backlog.
A read-only, session-wide longitudinal view chaining every sprint's history together — velocity, sprint outcome, live simulation risk, QA/defects, retrospective activity, and XP/achievement growth — so a team can see how it evolved across sprints instead of only ever looking at one sprint in isolation. Requires zero new database tables or columns: every number is derived, on demand, from Sprint/SprintOutcome/ SimulationEvent/RetrospectiveEntry/XpLedgerEntry/AchievementUnlock rows that already exist.
core/arc/service.py— the one correctness rule the whole module depends on:close_sprint()detaches any incomplete story after computing that sprint'sSprintOutcome, so a CLOSED sprint's real planned/completed points must always come from the frozenSprintOutcome, never from a livecapacity_metrics()call (which would under-count them). The not-yet-closed sprint is the only one that uses the live engine functions.- Every per-sprint entry combines, from existing engines only: outcome
scores (Phase 7), live health/risk (Phase 8), defects/QA (Phase 6),
a simulation event summary (Phase 8's
team_simulation_analytics()), retrospective activity split into "raised here" vs. "carried in from an earlier sprint's retro" (Phase 9), the real process-risk reduction an applied action item contributes (reusingcore/simulation/health.py::applied_retro_action_reduction()— made public for exactly this reuse, no second formula), and XP/achievements awarded within that sprint's real time window (the same windowing Phase 8'stimeline.pyalready established). - Two small, explicit, documented formulas, both in
core/arc/service.py: a_trend()helper (first-vs-last data point, polarity-aware since a rising risk is "declining" but a rising score is "improving") reused for every trend series, and a retrospective follow-through rate (APPLIED / (ADOPTED + APPLIED)— of the ideas a team committed to, how many were actually carried out). Trends are built only from CLOSED sprints — frozen, directly comparable facts — never blended with the live, still-changing active sprint. - One new endpoint,
GET /sessions/{id}/arc— active-membership only, no SessionRole gating, matching the existing/metricspattern (a shared team dashboard, not a private or role-restricted one). No new realtime event: the frontend simply refetches the arc on the existing event types it already knows about. - A new
/sessions/[id]/arcpage (linked from the session lobby): trend cards (velocity, outcome score, overall risk) with smallrechartsline charts, a retrospective follow-through summary, and a chronological sprint-by-sprint history list.
Surfaces two things a Scrum Master already has, right when they need them — at the moment of creating a new sprint — instead of requiring a detour to the Metrics or Arc pages first.
core/arc/service.py::sprint_planning_assistant()— a suggested capacity for the new sprint, built entirely from Phase 6's existingvelocity_metrics()plus the Project Arc's own_trend/_population_stdevhelpers (no new formula). The suggestion is the honest rounded average of the team's own real recent velocity; predictability (spread) is reported alongside it as context, never folded into the number, so the Scrum Master's own judgment — not an opaque formula — is what adjusts for volatility.Nonewhen there isn't yet a closed sprint to learn from.- One new endpoint,
GET /sessions/{id}/sprints/planning-assistant— active-membership only, same shared-dashboard pattern as/metricsand/arc. The Improvement Backlog shown alongside it is not a new endpoint: the frontend reuses Phase 9's existingGET /retrospective/action-itemsunchanged, filtered client-side to non-terminal (OPEN/ADOPTED) items. - The Backlog page's "New Sprint" form gained a real
capacity_pointsinput (previously missing entirely, even though the backend already accepted it) pre-filled with the suggestion, plus the open Improvement Backlog shown right in the same modal. - Bug fix discovered during this phase's E2E verification:
db/database.py's real engine usedStaticPool— safe only for the in-memory engines every test file builds, but unsafe for this real, file-backed one: it forces every request thread to share one raw sqlite3 connection object, which reproducibly caused "database disk image is malformed" and a CPU-spinning hang under the Backlog page's normal concurrentPromise.all()page load (unchanged since Phase 2). Fixed by removingStaticPoolso the real engine uses SQLAlchemy's default pooling; WAL mode already provides the concurrent-reader safety a real pool needs. Verified with an 80-request concurrent burst before and after.
- Next.js (
frontend/) is the primary, actively developed UI going forward, including all future multiplayer/session/game features. - The legacy server-rendered UI (
templates/+static/) is frozen: it still runs (nothing has been removed), but receives no new features. It will be removed once the Next.js app has full parity with it for the standalone requirement/test-case authoring flows it currently serves.
This section documents what a real (non-localhost) deployment needs, and what was fixed to make one possible. No hosting provider is assumed or configured here — see "Deployment prerequisites" below for what any provider you choose will need from you.
| Variable | Purpose | Required in production? |
|---|---|---|
SECRET_KEY |
JWT signing key | Yes. The app falls back to a well-known dev default and now logs a startup warning if DEBUG=false and this hasn't been set — every token is forgeable otherwise. |
DEBUG |
false in production |
Controls uvicorn --reload via main.py and the SECRET_KEY check above. |
CORS_ALLOWED_ORIGINS |
Comma-separated list of allowed frontend origin(s), e.g. https://app.example.com |
Yes, if the frontend is served from anywhere other than localhost:3000/3002. Unset, the API only accepts the dev localhost origins and a real frontend's requests will be rejected by CORS. |
BUILDMESH_DB_URL_OVERRIDE |
SQLAlchemy URL for the SQLite file (e.g. to relocate it to a mounted persistent volume) | Optional — defaults to data/requirements.db relative to the repo. The same variable also controls alembic upgrade head. |
PORT |
Backend listen port when run via python main.py |
Optional — many hosting platforms assign this for you; defaults to 8001. |
NEXT_PUBLIC_API_URL (frontend) |
The backend's public URL | Yes — baked into the frontend at build time; must point at the real deployed backend, not 127.0.0.1. |
ADMIN_USERNAME / ADMIN_EMAIL / ADMIN_PASSWORD |
Overrides for scripts/seed_admin.py |
Recommended — the script otherwise creates admin/admin123, printing a warning if you don't override the password. |
LLM_ENABLED / LLM_PROVIDER / OLLAMA_* |
Optional local-LLM configuration (Phase 3) | No — everything has a deterministic fallback; only needed if you want real LLM-generated content. |
See .env.example and frontend/.env.example for the full set with
inline comments.
Whatever host you choose will need to:
- Run the backend with
--ws websockets-sansio(orpython main.py, which now sets this itself) — see "Real-Time Layer (Phase 5)" above for why this is not optional. - Run
alembic upgrade headagainst a persistent volume/database path before the app's first start on that database (see "Database Migrations" above). - Serve the backend over HTTPS/WSS if the frontend is served over HTTPS
— browsers will not open a plain
ws://connection from anhttps://page. - Build the frontend with
NEXT_PUBLIC_API_URLalready set to the backend's real public URL (it's inlined at build time, not read at request time). - Persist the SQLite file (and its
-wal/-shmsiblings) across restarts/redeploys — either a mounted volume or, for real multi-user production use, a migration to a networked database (already flagged as future work indb/database.py's own WAL-mode comment; SQLite here is a deliberate, documented choice for this project's current scale, not an oversight).
- Production frontend build was broken.
next buildfailed with tworeact/no-unescaped-entitiesESLint errors (raw apostrophes in JSX text in the Backlog and Retrospective pages). Fixed;npm run buildnow succeeds cleanly. - The documented-as-required
--ws websockets-sansioflag was missing from every checked-in entry point except the README's own manual instructions —main.py,frontend/package.json'sdev:backendscript, andfrontend/scripts/dev-with-backend.mjsall started uvicorn without it, meaning anyone usingpython main.pyornpm run dev(rather than typing the documented manual command) would hit the real, previously-documented WebSocket crash bug from the Phase 5 report. All three now pass the flag. - CORS origins were hardcoded to the four dev localhost URLs with no
way to add a real deployment's frontend origin. Now reads
CORS_ALLOWED_ORIGINS(comma-separated), falling back to the same dev defaults when unset so local development is unaffected. - No startup warning for an insecure default
SECRET_KEY. A production deployment (DEBUG=false) that forgot to setSECRET_KEYwould silently sign every JWT with a well-known, publicly-documented default. Now logs a loud warning at startup in that case (a hard failure was deliberately avoided so existing dev/test workflows, which rely on the default, are unaffected). - The real, file-backed database engine had no way to relocate the
SQLite file without editing
db/database.py—alembic's own migration runner already supportedBUILDMESH_DB_URL_OVERRIDEfor this; the running app now honors the same variable. scripts/seed_admin.pyhad no way to avoid the defaultadmin123password other than manually changing the account afterward. Now acceptsADMIN_USERNAME/ADMIN_EMAIL/ADMIN_PASSWORDoverrides and warns if the default password is still used.- A
LICENSEfile was added (MIT) — the README already declared an MIT license, but noLICENSEfile existed in the repository to back it.
- No deployment provider, Dockerfile, or CI config was added — none was requested, and guessing a provider's exact configuration would very likely be wrong. The table above lists what any provider will need.
Base.metadata.create_all()still runs at app startup (see "Database Migrations" above) — removing it would change existing first-run behavior beyond what release preparation should touch; the run-migrations-first ordering is now called out explicitly instead.- No dependency versions were pinned beyond what
requirements.txtalready specified, and no new dependency was added anywhere in the stack.
MIT